Compare commits

..

161 Commits

Author SHA1 Message Date
Will Miao
9199950b74 feat(release): update version to 0.9.6 and add release notes for critical performance optimizations and new features 2025-10-07 17:41:58 +08:00
Will Miao
4c7e31687b feat(recipe_scanner): reset cached state on active library change 2025-10-07 08:07:44 +08:00
Will Miao
75e207b520 fix(banner): remove redundant community support banner registration 2025-10-06 22:39:49 +08:00
Will Miao
631289b75e feat(banner): add community support banner with Ko-fi integration and translations 2025-10-06 22:39:21 +08:00
Will Miao
1b958d0a5d feat(modal): use CSS variables for header height and improve recipe modal layout 2025-10-06 16:28:56 +08:00
Will Miao
35fdf9020d docs(README): update instructions for settings.json file creation in Portable Edition 2025-10-06 15:32:37 +08:00
Will Miao
45926b1dca feat(constants): add CHROMA model to BASE_MODELS and update categories 2025-10-06 08:48:15 +08:00
Will Miao
686ba5024d fix(tests): add loadingManager mocks in settingsManager tests 2025-10-06 08:24:58 +08:00
pixelpaws
cf375c7c86 Merge pull request #537 from willmiao/codex/implement-video-lazy-loading-with-queue
fix: throttle model card video lazy loading
2025-10-06 08:07:24 +08:00
pixelpaws
5e53d76f44 fix(model-card): throttle preview video loading 2025-10-06 07:45:51 +08:00
Will Miao
7757f72859 Enhance test for saving paths to ensure cross-platform compatibility in folder paths 2025-10-05 22:40:43 +08:00
pixelpaws
c8cc584049 Merge pull request #536 from willmiao/codex/add-unit-tests-for-config-saving-paths
Add regression tests for Config save path handling
2025-10-05 22:24:57 +08:00
pixelpaws
2cdd269bba Merge pull request #535 from willmiao/codex/add-tests-for-migration-utility-functions
test: cover example images migration flows
2025-10-05 22:24:42 +08:00
pixelpaws
d2d97ae5bb Merge pull request #534 from willmiao/codex/add-tests-for-cache-middleware
test: add cache control middleware coverage
2025-10-05 22:24:26 +08:00
pixelpaws
d08d77c555 Merge pull request #533 from willmiao/codex/add-async-test-module-for-lifecycle
test: add LoRA manager lifecycle coverage
2025-10-05 22:24:12 +08:00
pixelpaws
92f8d2139a Merge pull request #532 from willmiao/codex/create-tests-for-statsroutes
test: add coverage for stats routes endpoints
2025-10-05 22:23:59 +08:00
pixelpaws
50f2c2dfe6 test(config): cover save path migration flows 2025-10-05 22:19:36 +08:00
pixelpaws
3539c453d3 test(utils): cover example images migrations 2025-10-05 22:19:29 +08:00
pixelpaws
1631122f95 test(middleware): add cache control coverage 2025-10-05 22:19:20 +08:00
pixelpaws
8fcb979544 test(routes): cover lora manager lifecycle 2025-10-05 22:19:10 +08:00
pixelpaws
8a5af0b7f3 test(routes): add stats routes coverage 2025-10-05 22:18:59 +08:00
Will Miao
cb1f08d556 Fix low contrast on nav-item hover 2025-10-05 21:10:01 +08:00
pixelpaws
1150267765 Merge pull request #531 from willmiao/codex/-activelibrary
fix(locales): translate library selection strings
2025-10-05 21:04:10 +08:00
pixelpaws
5c1252548d fix(locales): translate library selection strings 2025-10-05 21:03:21 +08:00
Will Miao
3c7cdf5db8 feat(header): enhance navigation and search functionality with context-aware behavior 2025-10-05 20:58:14 +08:00
Will Miao
9ac4203b1c test(example-images): allow monkeypatching os.startfile on linux 2025-10-05 16:30:44 +08:00
pixelpaws
d0800510db Merge pull request #529 from willmiao/codex/update-file-url-formatting-methods
fix: route recipe previews through preview API
2025-10-05 15:53:26 +08:00
pixelpaws
f8ba551cc4 fix(recipes): use preview endpoint for recipe images 2025-10-05 15:49:18 +08:00
Will Miao
413444500e refactor(model_scanner): normalize path comparisons for model roots
fix(example_images_download_manager): re-raise specific exception on download error

refactor(usage_stats): define constants locally to avoid conditional imports

test(example_images_download_manager): update exception handling in download tests

test(example_images_file_manager): differentiate between os.startfile and subprocess.Popen in tests

test(example_images_paths): ensure valid example images root with single-library mode

test(usage_stats): use string literals for metadata payload to avoid conditional imports
2025-10-05 15:48:50 +08:00
pixelpaws
e21d5835ec Merge pull request #524 from willmiao/codex/add-async-tests-for-websocketmanager
Add websocket broadcast and usage stats tests
2025-10-05 15:19:40 +08:00
pixelpaws
f2f354e478 Merge pull request #528 from willmiao/codex/add-bulk-action-set-content-rating, fixes #428
Add bulk content rating update action
2025-10-05 15:19:19 +08:00
pixelpaws
b195d4569c test(usage-stats): allow metadata registry monkeypatch 2025-10-05 15:13:20 +08:00
pixelpaws
3b77fed72d feat(bulk): add bulk content rating action 2025-10-05 15:09:43 +08:00
pixelpaws
fc64e97f92 Merge pull request #525 from willmiao/codex/develop-tests-for-metadatasyncservice-and-modelscanner
Add coverage for metadata sync service and scanner reconciliation
2025-10-05 15:03:36 +08:00
pixelpaws
1da0434454 Merge pull request #526 from willmiao/codex/add-tests-for-utility-functions
test: add coverage for utility helpers
2025-10-05 15:03:20 +08:00
pixelpaws
cf2fe40612 Merge pull request #527 from willmiao/codex/add-unit-tests-for-metadata-components
Add metadata collector unit tests and fixtures
2025-10-05 15:03:04 +08:00
pixelpaws
8f46433ff7 Merge pull request #523 from willmiao/codex/add-tests-for-settingsmanager-migration
Add tests for settings migrations and service registry lazy loading
2025-10-05 15:02:44 +08:00
pixelpaws
f3be3ae269 Merge pull request #522 from willmiao/codex/add-tests-for-example-images-pipeline
test: add example images route and utility coverage
2025-10-05 15:02:07 +08:00
pixelpaws
cfec5447d3 test(metadata): add collector coverage 2025-10-05 14:44:17 +08:00
pixelpaws
2d36b461cf test(utils): add coverage for helper utilities 2025-10-05 14:43:21 +08:00
pixelpaws
5e23e4b13d test(metadata): cover sync service and scanner reconciliation 2025-10-05 14:42:13 +08:00
pixelpaws
badae2e8b3 test: cover websocket broadcasts and usage stats 2025-10-05 14:41:47 +08:00
pixelpaws
9e64531de6 test(settings): cover migrations and registry lazy loading 2025-10-05 14:41:24 +08:00
pixelpaws
fdec8d283c test(example-images): expand coverage for routes and utilities 2025-10-05 14:40:48 +08:00
Will Miao
9abedbf7cb fix(metadata-sync): improve error handling for deleted CivitAI models, fixes #497 2025-10-05 11:05:52 +08:00
pixelpaws
66004c1cdc Merge pull request #520 from willmiao/codex/adjust-example-images-download-to-use-library-name
fix: keep example image downloads in initial library
2025-10-05 10:08:26 +08:00
pixelpaws
5b564cd8a3 fix(example-images): pin downloads to start library 2025-10-05 09:10:25 +08:00
pixelpaws
2e79970e6e Merge pull request #519 from willmiao/codex/update-example-images-download-flow
fix: reuse migrated example image folders before download
2025-10-05 09:04:06 +08:00
pixelpaws
67c82ba6ea fix(example-images): reuse migrated folders during downloads 2025-10-05 08:37:11 +08:00
Will Miao
98425f37b8 fix(download-manager): improve handling of civitai payloads to avoid empty dictionaries 2025-10-05 07:29:11 +08:00
Will Miao
9d22dd3465 fix(model-library): update response structure to return model versions directly 2025-10-04 22:06:33 +08:00
pixelpaws
837138db49 Merge pull request #517 from willmiao/codex/determine-example_images_path-structure
feat: namespace example image storage by library
2025-10-04 20:42:15 +08:00
pixelpaws
d43d992362 feat(example-images): namespace storage by library 2025-10-04 20:29:49 +08:00
Will Miao
16b611cb7e Simplify settings file location and configuration 2025-10-04 18:42:53 +08:00
Will Miao
8dde2d5e0d feat(websocket-manager): implement caching for initialization progress and enhance broadcast functionality 2025-10-04 18:19:20 +08:00
Will Miao
22b0b2bd24 fix(model-card): correct query parameter handling in versioned preview URL 2025-10-04 17:32:16 +08:00
Will Miao
056f727bfd feat(model-scanner): enhance page type determination for model types 2025-10-04 17:08:02 +08:00
Will Miao
0aa6c53c1f feat(initialization): add support for embeddings page type and log progress updates 2025-10-04 14:11:27 +08:00
pixelpaws
d9b0660611 Merge pull request #516 from willmiao/codex/investigate-library-switching-functionality-issue
feat: serve dynamic preview assets after library switch
2025-10-04 10:51:52 +08:00
pixelpaws
d01666f4e2 feat(previews): serve dynamic library previews 2025-10-04 10:38:06 +08:00
Will Miao
51bee87cd0 fix(persistence): improve handling of lora_info attributes in recipe persistence 2025-10-04 09:52:53 +08:00
pixelpaws
3041b443e5 Merge pull request #515 from willmiao/codex/add-library-selection-in-settings-modal
Add tests for settings library switcher
2025-10-04 09:22:11 +08:00
pixelpaws
d95e6c939b test(settings): cover library switch workflow 2025-10-04 09:07:02 +08:00
pixelpaws
fd38c63b35 Merge pull request #514 from willmiao/codex/add-multi-library-endpoints-for-frontend
test: add coverage for settings library endpoints
2025-10-04 08:43:03 +08:00
pixelpaws
b69c24ae14 test(routes): cover settings library endpoints 2025-10-04 08:38:59 +08:00
pixelpaws
65a0c00e33 Merge pull request #513 from willmiao/codex/add-link-button-to-settings-modal-header
Adjust settings modal location shortcut styling
2025-10-04 07:57:43 +08:00
pixelpaws
b12a5ef133 style(settings): restyle settings location shortcut 2025-10-04 07:52:10 +08:00
pixelpaws
9e1b92c26e Merge pull request #512 from willmiao/codex/analyze-and-add-tests-for-misc-routes
test: improve misc routes coverage
2025-10-04 05:25:58 +08:00
pixelpaws
3922aec36e test(routes): extend misc routes coverage 2025-10-04 05:10:43 +08:00
pixelpaws
41cca8e56d Merge pull request #511 from willmiao/codex/refactor-misc_routes.py-and-add-tests
refactor: modularize misc route controller
2025-10-03 22:47:22 +08:00
pixelpaws
2d37a7341a fix(routes): await trained words extraction 2025-10-03 22:45:25 +08:00
pixelpaws
40e3c6134c refactor(routes): modularize misc route handling 2025-10-03 22:19:09 +08:00
pixelpaws
edddd47a1e Merge pull request #510 from willmiao/codex/update-default-library-folder-path-handling
fix: rename legacy default library to comfyui
2025-10-03 21:50:41 +08:00
pixelpaws
4ea6f38645 fix(config): rename legacy default library 2025-10-03 21:24:17 +08:00
Will Miao
40d998a026 fix(settings): use timezone-aware datetime for current timestamp
fix(tests): normalize stored paths in library upsert test
2025-10-03 20:59:47 +08:00
pixelpaws
3af8f151ac Merge pull request #509 from willmiao/codex/implement-features-from-multi-library-design
feat: add multi-library backend support
2025-10-03 20:37:59 +08:00
pixelpaws
e066fa6873 feat(settings): add multi-library backend support 2025-10-03 20:08:35 +08:00
Will Miao
6bd94269d4 refactor(model-scanner): remove unused model scanning methods 2025-10-03 18:58:02 +08:00
Will Miao
c90edec18a feat(multi-library): add design documentation for multi-library management in standalone mode 2025-10-03 18:34:52 +08:00
Will Miao
cbb302614c Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-03 15:23:30 +08:00
Will Miao
c54611a11b fix(metadata-service): change log level to debug for SQLite and fallback provider registration 2025-10-03 15:23:28 +08:00
pixelpaws
88f249649a Merge pull request #508 from willmiao/codex/sync-persistent-model-cache-metadata-updates
fix: sync persistent cache with metadata updates
2025-10-03 15:06:30 +08:00
pixelpaws
fe9fbdb93c fix(cache): sync persistent metadata updates 2025-10-03 14:57:44 +08:00
Will Miao
28bc966b76 feat(model-scanner): enhance cache loading with progress reporting and fallback to full scan 2025-10-03 14:31:08 +08:00
Will Miao
77bbf85b52 feat(persistent-cache): implement SQLite-based persistent model cache with loading and saving functionality 2025-10-03 11:00:51 +08:00
Will Miao
3b1990e97a feat(scanner): enhance model scanning with cache build result and progress reporting 2025-10-02 21:25:09 +08:00
Will Miao
375b5a49f3 fix(config): update standalone mode environment variable usage 2025-10-02 09:40:24 +08:00
pixelpaws
392c157cb5 Merge pull request #503 from willmiao/codex/add-hebrew-locale-to-tests
fix(i18n): add hebrew locale coverage
2025-09-30 22:07:52 +08:00
pixelpaws
6f5bf4b582 fix(i18n): add hebrew locale coverage 2025-09-30 22:03:44 +08:00
pixelpaws
2e3f48ebb7 Merge pull request #426 from start-life/main
Adding Hebrew language
2025-09-30 21:51:50 +08:00
Will Miao
e4a2c518bb fix(preset): update filePath retrieval method in removePreset function 2025-09-30 21:41:30 +08:00
pixelpaws
f19fb68b4c Merge pull request #501 from willmiao/codex/update-downloadmanager-to-handle-multiple-download-urls
feat: retry mirror downloads sequentially
2025-09-30 17:17:34 +08:00
pixelpaws
9121c12a2c feat(download): retry mirror urls sequentially 2025-09-30 17:14:59 +08:00
Will Miao
d0fe28cfe2 fix(recipe): validate modelVersionId before fetching hash from cache or Civitai 2025-09-28 09:18:59 +08:00
Will Miao
656e3e43be fix(imports): update import paths for ensure_settings_file to use relative imports 2025-09-28 08:40:09 +08:00
pixelpaws
c2c1772371 Merge pull request #496 from willmiao/codex/find-best-practices-for-settings-file-storage
feat(settings): persist settings in user config directory
2025-09-28 07:06:02 +08:00
pixelpaws
88d5caf642 feat(settings): migrate settings to user config dir 2025-09-27 22:22:15 +08:00
pixelpaws
1684978693 Merge pull request #491 from willmiao/codex/replace-spaces-in-embedding-paths
Fix embedding relative paths by replacing spaces
2025-09-26 09:06:56 +08:00
pixelpaws
8e4927600f fix(embeddings): replace spaces in relative paths 2025-09-26 09:02:46 +08:00
pixelpaws
4d72dc57e7 Merge pull request #490 from willmiao/codex/remove-comfy-field-from-images-metadata
fix: strip comfy metadata from civitai model images
2025-09-26 08:59:30 +08:00
pixelpaws
e7316b3389 fix(civitai): strip comfy metadata from images 2025-09-26 08:55:46 +08:00
start-life
e17b374606 Update zh-CN.json 2025-09-26 02:16:58 +03:00
Will Miao
141f83065f fix(locales): update language selection text in Chinese 2025-09-25 21:14:58 +08:00
pixelpaws
6381dbafc1 Merge pull request #488 from willmiao/codex/fix-bespoke-import_from-loader-issue
test: standardize backend package imports
2025-09-25 16:51:17 +08:00
pixelpaws
fc9db4510f test: fix duplicate pytest import 2025-09-25 16:44:43 +08:00
pixelpaws
66abf736c9 Merge pull request #487 from willmiao/codex/fix-lora-information-import-issue
fix: parse civitai image LoRAs from hash metadata
2025-09-25 16:00:48 +08:00
pixelpaws
af713470c1 fix(recipes): parse loras from civitai hashes 2025-09-25 15:59:44 +08:00
pixelpaws
93a51d2bcb Merge pull request #486 from willmiao/codex/enable-test-coverage-metrics-in-ci
ci: add pytest coverage reporting
2025-09-25 15:58:47 +08:00
pixelpaws
3f3e06de8a Fix pytest command in backend tests workflow 2025-09-25 15:57:59 +08:00
pixelpaws
7315aac9d8 ci: add pytest coverage reporting 2025-09-25 15:47:07 +08:00
pixelpaws
d933308a6f Merge pull request #485 from willmiao/codex/expand-vitest-coverage-for-frontend-components
test(frontend): extend coverage for comfyui widgets and helpers
2025-09-25 15:37:48 +08:00
pixelpaws
3baf93dcc5 test(frontend): extend coverage for comfyui widgets and helpers 2025-09-25 15:32:25 +08:00
pixelpaws
6ba14bd8fe Merge pull request #484 from willmiao/codex/fix-update-check-in-offline-mode
Fix offline update logging and respect update notification toggle
2025-09-25 14:54:28 +08:00
pixelpaws
7499570766 fix(updates): avoid network stack traces offline 2025-09-25 14:50:06 +08:00
start-life
003ee55a75 Merge branch 'main' into main 2025-09-25 09:28:30 +03:00
pixelpaws
b0cc42ef1f Merge pull request #483 from willmiao/codex/introduce-integration-and-contract-tests
test: add aiohttp integration coverage for ServiceRegistry
2025-09-25 14:23:53 +08:00
pixelpaws
23679ec3f5 chore(tests): clean integration route header 2025-09-25 14:17:45 +08:00
Will Miao
da52e5b9dd fix(settings): improve logic for auto-setting default root paths based on folder presence 2025-09-25 10:56:09 +08:00
Will Miao
c4e357793f Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-09-25 10:43:14 +08:00
Will Miao
6c3424029c fix(recipe image): optimize image saving and update PNG metadata handling, fixes #481 2025-09-25 10:43:10 +08:00
pixelpaws
dd9e6a5b69 Merge pull request #482 from willmiao/codex/add-pytest-modules-for-untested-services
Add backend service and route test coverage
2025-09-25 09:48:27 +08:00
pixelpaws
095320ef72 test(routes): tidy lora route test imports 2025-09-25 09:40:25 +08:00
pixelpaws
35f7674bcd Merge pull request #480 from willmiao/codex/modularize-i18n-tests-into-smaller-cases
test(i18n): modularize translation validation
2025-09-25 09:25:06 +08:00
pixelpaws
26b36c123d test(i18n): modularize translation validation 2025-09-25 09:21:08 +08:00
Will Miao
c85e694c1d docs: update repository guidelines for clarity and consistency 2025-09-25 08:36:15 +08:00
Will Miao
ec05282db6 fix(coverage): improve Vitest CLI path handling and error checking 2025-09-25 08:11:48 +08:00
pixelpaws
3d6f9b226f Merge pull request #479 from willmiao/codex/execute-phase-5-tasks-and-update-roadmap
Add frontend coverage workflow and reporting script
2025-09-25 07:00:04 +08:00
pixelpaws
eda6df4a5d chore(ci): add frontend coverage workflow 2025-09-24 23:22:32 +08:00
Will Miao
d504f89f6a Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-09-24 21:11:43 +08:00
Will Miao
14c468f2a2 feat(video): enhance video handling in model cards with lazy loading and autoplay settings, see #446 2025-09-24 21:11:36 +08:00
pixelpaws
2a99b0e46f Merge pull request #478 from willmiao/codex/execute-phase-4-tasks-from-roadmap
Add interaction-level frontend regression tests
2025-09-24 20:38:51 +08:00
pixelpaws
ae8914f5c8 test(frontend): add interaction regression suites 2025-09-24 20:33:41 +08:00
pixelpaws
0c9f8971ce Merge pull request #477 from willmiao/codex/execute-phase-3-tasks-and-update-roadmap
test: add embeddings and recipes manager suites
2025-09-24 20:18:37 +08:00
pixelpaws
d7a75ea4e5 test(frontend): cover embeddings and recipes managers 2025-09-24 20:15:38 +08:00
pixelpaws
3ad8d8b17c Merge pull request #476 from willmiao/codex/plan-next-tasks-based-on-roadmap-juork7
test(frontend): cover filtering flows for lora and checkpoints
2025-09-24 17:54:50 +08:00
pixelpaws
39225dc204 test(frontend): add filtering coverage for model pages 2025-09-24 17:50:04 +08:00
pixelpaws
4fb69f7d89 Merge pull request #475 from willmiao/codex/plan-next-tasks-based-on-roadmap-gpw3fe
Add checkpoints page smoke tests
2025-09-24 17:22:22 +08:00
pixelpaws
0890c6ad24 test(frontend): add checkpoints manager smoke tests 2025-09-24 17:18:20 +08:00
pixelpaws
dd81809589 Merge pull request #474 from willmiao/codex/plan-next-steps-for-roadmap-tasks
test(frontend): add loras page manager suite
2025-09-24 17:09:38 +08:00
pixelpaws
f0672beb46 test(frontend): add loras page manager suite 2025-09-24 16:22:17 +08:00
pixelpaws
cc5301e710 Merge pull request #473 from willmiao/codex/plan-next-tasks-based-on-roadmap
fix(frontend): validate AppCore initialization wiring
2025-09-24 16:15:05 +08:00
pixelpaws
9d5ec43c4e fix(frontend): correct AppCore example images initialization 2025-09-24 16:10:27 +08:00
pixelpaws
6d41211b07 Merge pull request #472 from willmiao/codex/plan-and-update-frontend-testing-roadmap
Add AppCore page orchestration tests
2025-09-24 15:56:17 +08:00
pixelpaws
d58b61eed5 test(frontend): cover appcore page features 2025-09-24 15:55:50 +08:00
pixelpaws
4b53d98bfc Merge pull request #471 from willmiao/codex/organize-frontend-tests-into-new-directories
test(frontend): document dom fixture workflow
2025-09-24 15:44:23 +08:00
pixelpaws
f51f354e48 test(frontend): add dom fixture helpers 2025-09-24 15:39:52 +08:00
Will Miao
59d027181d refactor: remove obsolete JSON files and add new version metadata for LORA model 2025-09-24 10:56:06 +08:00
Will Miao
0d0988c090 feat: add functionality to attach model files to version data in SQLiteModelMetadataProvider 2025-09-24 10:55:55 +08:00
Will Miao
dc2de50924 bump: update version to 0.9.5 in pyproject.toml 2025-09-24 09:16:50 +08:00
start-life
a3c28c1003 Update zh-TW.json 2025-09-12 03:33:34 +03:00
start-life
f4b7c9a138 Update zh-CN.json 2025-09-12 03:33:09 +03:00
start-life
6b860b5f29 Update ru.json 2025-09-12 03:32:15 +03:00
start-life
37dfcd6abd Update ko.json 2025-09-12 03:31:57 +03:00
start-life
bc2fca3a4f Update ja.json 2025-09-12 03:31:35 +03:00
start-life
f8ef159656 Update fr.json 2025-09-12 03:31:12 +03:00
start-life
b2b8a9d37e Update es.json 2025-09-12 03:30:56 +03:00
start-life
15ae4031b7 Update de.json 2025-09-12 03:30:39 +03:00
start-life
688976ce3b Update en.json 2025-09-12 03:30:14 +03:00
start-life
a548af01dc Update settings_modal.html
Adding Hebrew language
2025-09-12 03:28:45 +03:00
start-life
0dd52eceb3 Update index.js
Adding Hebrew language
2025-09-12 03:26:08 +03:00
start-life
b8c6cf4ac1 Add files via upload
Adding Hebrew language
2025-09-12 03:20:38 +03:00
156 changed files with 15156 additions and 4186 deletions

69
.github/workflows/backend-tests.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Backend Tests
on:
push:
branches:
- main
- master
paths:
- 'py/**'
- 'standalone.py'
- 'tests/**'
- 'requirements.txt'
- 'requirements-dev.txt'
- 'pyproject.toml'
- 'pytest.ini'
- '.github/workflows/backend-tests.yml'
pull_request:
paths:
- 'py/**'
- 'standalone.py'
- 'tests/**'
- 'requirements.txt'
- 'requirements-dev.txt'
- 'pyproject.toml'
- 'pytest.ini'
- '.github/workflows/backend-tests.yml'
jobs:
pytest:
name: Run pytest with coverage
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: |
requirements.txt
requirements-dev.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run pytest with coverage
env:
COVERAGE_FILE: coverage/backend/.coverage
run: |
mkdir -p coverage/backend
python -m pytest \
--cov=py \
--cov=standalone \
--cov-report=term-missing \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=html:coverage/backend/html \
--cov-report=json:coverage/backend/coverage.json
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: coverage/backend
if-no-files-found: warn

52
.github/workflows/frontend-tests.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Frontend Tests
on:
push:
branches:
- main
- master
paths:
- 'package.json'
- 'package-lock.json'
- 'vitest.config.js'
- 'tests/frontend/**'
- 'static/js/**'
- 'scripts/run_frontend_coverage.js'
- '.github/workflows/frontend-tests.yml'
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
- 'vitest.config.js'
- 'tests/frontend/**'
- 'static/js/**'
- 'scripts/run_frontend_coverage.js'
- '.github/workflows/frontend-tests.yml'
jobs:
vitest:
name: Run Vitest with coverage
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run frontend tests with coverage
run: npm run test:coverage
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: frontend-coverage
path: coverage/frontend
if-no-files-found: warn

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ cache/
civitai/
node_modules/
coverage/
.coverage

View File

@@ -1,19 +1,22 @@
# Repository Guidelines
# Repository Guidelines
## Project Structure & Module Organization
ComfyUI LoRA Manager pairs a Python backend with lightweight browser scripts. Backend modules live in `py/`, organized by responsibility: HTTP entry points under `routes/`, feature logic in `services/`, reusable helpers within `utils/`, and custom nodes in `nodes/`. Front-end widgets that extend the ComfyUI interface sit in `web/comfyui/`, while static images and templates are in `static/` and `templates/`. Shared localization files are stored in `locales/`, with workflow examples under `example_workflows/`. Tests currently reside alongside the source (`test_i18n.py`) until a dedicated `tests/` folder is introduced.
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
## Build, Test, and Development Commands
Install dependencies with `pip install -r requirements.txt` from the repo root. Launch the standalone server for iterative work via `python standalone.py --port 8188`; ComfyUI users can also load the extension directly through ComfyUI's custom node manager. Run backend checks with `python -m pytest test_i18n.py`, and target new test files explicitly (e.g. `python -m pytest tests/test_recipes.py` once added). Use `python scripts/sync_translation_keys.py` to reconcile locale keys after updating UI strings.
- <code>pip install -r requirements.txt</code> installs backend dependencies.
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
## Coding Style & Naming Conventions
Follow PEP 8 with four-space indentation and descriptive snake_case module/function names, mirroring files such as `py/services/settings_manager.py`. Classes remain PascalCase, constants UPPER_SNAKE_CASE, and loggers retrieved via `logging.getLogger(__name__)`. Prefer explicit type hints for new public APIs and docstrings that clarify side effects. JavaScript in `web/comfyui/` is modern ES modules; keep imports relative, favor camelCase functions, and mirror existing file suffixes like `_widget.js` for UI components.
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
## Testing Guidelines
Extend pytest coverage by co-locating tests near the code under test or in `tests/` with names like `test_<feature>.py`. When introducing new routes or services, add regression cases that mock ComfyUI dependencies (see the standalone mocking helpers in `standalone.py`). Prioritize deterministic fixtures for filesystem interactions and ensure translations include coverage when adding new locale keys. Always run `python -m pytest` before submitting work.
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes.
## Commit & Pull Request Guidelines
Commits follow the conventional pattern seen in `git log` (`feat(scope):`, `fix(scope):`, `chore(scope):`). Keep messages imperative and scoped to a single change. Pull requests should summarize the problem, detail the solution, list manual test evidence, and link any GitHub issues. Include UI screenshots or GIFs when front-end behavior changes, and call out migration steps (e.g., settings updates) in the PR description.
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments.
## Configuration & Localization Tips
Sample configuration defaults live in `settings.json.example`; copy it to `settings.json` and adjust model directories before running the standalone server. Whenever you add UI text, update `locales/<lang>.json` and run the translation sync script. Store reference assets in `civitai/` or `docs/` rather than mixing them with production templates, keeping the runtime folders (`static/`, `templates/`) deploy-ready.
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/&lt;lang&gt;.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.

View File

@@ -34,6 +34,14 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### v0.9.6
* **Critical Performance Optimization** - Introduced persistent model cache that dramatically accelerates initialization after startup and significantly reduces Python backend memory footprint for improved application performance.
* **Cross-Browser Settings Synchronization** - Migrated nearly all settings to the backend, ensuring your preferences sync automatically across all browsers for a seamless multi-browser experience.
* **Protected User Settings Location** - Relocated user settings (settings.json) to the user config directory (accessible via the link icon in Settings), preventing accidental deletion during reinstalls or updates.
* **Global Context Menu** - Added a new global context menu accessible by right-clicking on empty page areas, providing quick access to global operations with more features coming in future updates.
* **Multi-Library Support** - Introduced support for managing multiple libraries, allowing you to easily switch between different model collections (advanced usage, documentation in progress).
* **Bug Fixes & Stability Improvements** - Various bug fixes and enhancements for improved stability and reliability.
### v0.9.3
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
@@ -141,7 +149,7 @@ Enhance your Civitai browsing experience with our companion browser extension! S
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
4. Run run.bat
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
@@ -209,7 +217,7 @@ You can combine multiple patterns to create detailed, organized filenames for yo
You can now run LoRA Manager independently from ComfyUI:
1. **For ComfyUI users**:
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file.
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file located in your user settings folder (see paths above).
- Make sure dependencies are installed: `pip install -r requirements.txt`
- From your ComfyUI root directory, run:
```bash
@@ -231,8 +239,37 @@ You can now run LoRA Manager independently from ComfyUI:
```
- Access the interface through your browser at: `http://localhost:8188/loras`
> **Note:** Existing installations automatically migrate the legacy `settings.json` from the plugin folder to the user settings directory the first time you launch this version.
This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces.
## Testing & Coverage
### Backend
Install the development dependencies and run pytest with coverage reports:
```bash
pip install -r requirements-dev.txt
COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py \
--cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
```
HTML, XML, and JSON artifacts are stored under `coverage/backend/` so you can inspect hot spots locally or from CI artifacts.
### Frontend
Run the Vitest coverage suite to analyze widget hot spots:
```bash
npm run test:coverage
```
---
## Contributing

View File

@@ -0,0 +1,34 @@
# Multi-Library Management for Standalone Mode
## Requirements Summary
- **Independent libraries**: In standalone mode, users can maintain multiple libraries, where each library represents a distinct set of model folders (LoRAs, checkpoints, embeddings, etc.). Only one library is active at any given time, but users need a fast way to switch between them.
- **Library-specific settings**: The fields that vary per library are `folder_paths`, `default_lora_root`, `default_checkpoint_root`, and `default_embedding_root` inside `settings.json`.
- **Persistent caches**: Every library must have its own SQLite persistent model cache so that metadata generated for one library does not leak into another.
- **Backward compatibility**: Existing single-library setups should continue to work. When no multi-library configuration is provided, the application should behave exactly as before.
## Proposed Design
1. **Library registry**
- Extend the standalone configuration to hold a list of libraries, each identified by a unique name.
- Each entry stores the folder path configuration plus any library-scoped metadata (e.g. creation time, display name).
- The active library key is stored separately to allow quick switching without rewriting the full config.
2. **Settings management**
- Update `settings_manager` to load and persist the library registry. When a library is activated, hydrate the in-memory settings object with that library's folder configuration.
- Provide helper methods for creating, renaming, and deleting libraries, ensuring validation for duplicate names and path collisions.
- Continue writing the active library settings to `settings.json` for compatibility, while storing the registry in a new section such as `libraries`.
3. **Persistent model cache**
- Derive the SQLite file path from the active library, e.g. `model_cache_<library>.sqlite` or a nested directory structure like `model_cache/<library>/models.sqlite`.
- Update `PersistentModelCache` so it resolves the database path dynamically whenever the active library changes. Ensure connections are closed before switching to avoid locking issues.
- Migrate existing single cache files by treating them as the default library's cache.
4. **Model scanning workflow**
- Modify `ModelScanner` and related services to react to library switches by clearing in-memory caches, re-reading folder paths, and rehydrating metadata from the library-specific SQLite cache.
- Provide API endpoints in standalone mode to list libraries, activate one, and trigger a rescan.
5. **UI/UX considerations**
- In the standalone UI, introduce a library selector component that surfaces available libraries and offers quick switching.
- Offer feedback when switching libraries (e.g. spinner while rescanning) and guard destructive actions with confirmation prompts.
## Implementation Notes
- **Data migration**: On startup, detect if the old `settings.json` structure is present. If so, create a default library entry using the current folder paths and point the active library to it.
- **Thread safety**: Ensure that any long-running scans are cancelled or awaited before switching libraries to prevent race conditions in cache writes.
- **Testing**: Add unit tests for the settings manager to cover library CRUD operations and cache path resolution. Include integration tests that simulate switching libraries and verifying that the correct models are loaded.
- **Documentation**: Update user guides to explain how to define libraries, switch between them, and where the new cache files are stored.
- **Extensibility**: Keep the design open to future per-library settings (e.g. auto-refresh intervals, metadata overrides) by storing library data as objects instead of flat maps.

View File

@@ -0,0 +1,51 @@
# Frontend DOM Fixture Strategy
This guide outlines how to reproduce the markup emitted by the Django templates while running Vitest in jsdom. The aim is to make it straightforward to write integration-style unit tests for managers and UI helpers without having to duplicate template fragments inline.
## Loading Template Markup
Vitest executes inside Node, so we can read the same HTML templates that ship with the extension:
1. Use the helper utilities from `tests/frontend/utils/domFixtures.js` to read files under the `templates/` directory.
2. Mount the returned markup into `document.body` (or any custom container) before importing the module under test so its query selectors resolve correctly.
```js
import { renderTemplate } from '../utils/domFixtures.js'; // adjust the relative path to your spec
beforeEach(() => {
renderTemplate('loras.html', {
dataset: { page: 'loras' }
});
});
```
The helper ensures the dataset is applied to the container, which mirrors how Django sets `data-page` in production.
## Working with Partial Components
Many features are implemented as template partials located under `templates/components/`. When a test only needs a fragment (for example, the progress panel or context menu markup), load the component file directly:
```js
const container = renderTemplate('components/progress_panel.html');
const progressPanel = container.querySelector('#progress-panel');
```
This pattern avoids hand-written fixture strings and keeps the tests aligned with the actual markup.
## Resetting Between Tests
The shared Vitest setup clears `document.body` and storage APIs before each test. If a suite adds additional DOM nodes outside of the body or needs to reset custom attributes mid-test, use `resetDom()` exported from `domFixtures.js`.
```js
import { resetDom } from '../utils/domFixtures.js';
afterEach(() => {
resetDom();
});
```
## Future Enhancements
- Provide typed helpers for injecting mock script tags (e.g., replicating ComfyUI globals).
- Compose higher-level fixtures that mimic specific pages (loras, checkpoints, recipes) once those managers receive dedicated suites.

View File

@@ -0,0 +1,44 @@
# LoRA & Checkpoints Filtering/Sorting Test Matrix
This matrix captures the scenarios that Phase 3 frontend tests should cover for the LoRA and Checkpoint managers. It focuses on how search, filter, sort, and duplicate badge toggles interact so future specs can share fixtures and expectations.
## Scope
- **Components**: `PageControls`, `FilterManager`, `SearchManager`, and `ModelDuplicatesManager` wiring invoked through `CheckpointsPageManager` and `LorasPageManager`.
- **Templates**: `templates/loras.html` and `templates/checkpoints.html` along with shared filter panel and toolbar partials.
- **APIs**: Requests issued through `baseModelApi.fetchModels` (via `resetAndReload`/`refreshModels`) and duplicates badge updates.
## Shared Setup Considerations
1. Render full page templates using `renderLorasPage` / `renderCheckpointsPage` helpers before importing modules so DOM queries resolve.
2. Stub storage helpers (`getStorageItem`, `setStorageItem`, `getSessionItem`, `setSessionItem`) to observe persistence behavior without mutating real storage.
3. Mock `sidebarManager` to capture refresh calls triggered after sort/filter actions.
4. Provide fake API implementations exposing `resetAndReload`, `refreshModels`, `fetchFromCivitai`, `toggleBulkMode`, and `clearCustomFilter` so control events remain asynchronous but deterministic.
5. Supply a minimal `ModelDuplicatesManager` mock exposing `toggleDuplicateMode`, `checkDuplicatesCount`, and `updateDuplicatesBadgeAfterRefresh` to validate duplicate badge wiring.
## Scenario Matrix
| ID | Feature | Scenario | LoRAs Expectations | Checkpoints Expectations | Notes |
| --- | --- | --- | --- | --- | --- |
| F-01 | Search filter | Typing a query updates `pageState.filters.search`, persists to session, and triggers `resetAndReload` on submit | Validate `SearchManager` writes query and reloads via API stub; confirm LoRA cards pass query downstream | Same as LoRAs | Cover `enter` press and clicking search icon |
| F-02 | Tag filter | Selecting a tag chip adds it to filters, applies active styling, and reloads results | Tag stored under `filters.tags`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
| F-03 | Base model filter | Toggling base model checkboxes updates `filters.baseModel`, persists, and reloads | Ensure only LoRA-supported models show; toggle multi-select | Ensure SDXL/Flux base models appear as expected | Capture UI state restored from storage on next init |
| F-04 | Favorites-only | Clicking favorites toggle updates session flag and calls `resetAndReload(true)` | Button gains `.active` class and API called | Same | Verify duplicates badge refresh when active |
| F-05 | Sort selection | Changing sort select saves preference (legacy + new format) and reloads | Confirm `PageControls.saveSortPreference` invoked with option and API called | Same with checkpoints-specific defaults | Cover `convertLegacySortFormat` branch |
| F-06 | Filter persistence | Re-initializing manager loads stored filters/sort and updates DOM | Filters pre-populate chips/checkboxes; favorites state restored | Same | Requires simulating repeated construction |
| F-07 | Combined filters | Applying search + tag + base model yields aggregated query params for fetch | Assert API receives merged filter payload | Same | Validate toast messaging for active filters |
| F-08 | Clearing filters | Using "Clear filters" resets state, storage, and reloads list | `FilterManager.clearFilters` empties `filters`, removes active class, shows toast | Same | Ensure favorites-only toggle unaffected |
| F-09 | Duplicate badge toggle | Pressing "Find duplicates" toggles duplicate mode and updates badge counts post-refresh | `ModelDuplicatesManager.toggleDuplicateMode` invoked and badge refresh called after API rebuild | Same plus checkpoint-specific duplicate badge dataset | Connects to future duplicate-specific specs |
| F-10 | Bulk actions menu | Opening bulk dropdown keeps filters intact and closes on outside click | Validate dropdown class toggling and no unintended reload | Same | Guard against regression when dropdown interacts with filters |
## Automation Coverage Status
- ✅ F-01 Search filter, F-02 Tag filter, F-03 Base model filter, F-04 Favorites-only toggle, F-05 Sort selection, and F-09 Duplicate badge toggle are covered by `tests/frontend/components/pageControls.filtering.test.js` for both LoRA and checkpoint pages.
- ⏳ F-06 Filter persistence, F-07 Combined filters, F-08 Clearing filters, and F-10 Bulk actions remain to be automated alongside upcoming bulk mode refinements.
## Coverage Gaps & Follow-Ups
- Write Vitest suites that exercise the matrix for both managers, sharing fixtures through page helpers to avoid duplication.
- Capture API parameter assertions by inspecting `baseModelApi.fetchModels` mocks rather than relying solely on state mutations.
- Add regression cases for legacy storage migrations (old filter keys) once fixtures exist for older payloads.
- Extend duplicate badge coverage with scenarios where `checkDuplicatesCount` signals zero duplicates versus pending calculations.

View File

@@ -8,16 +8,26 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
| --- | --- | --- | --- | --- |
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | 🟡 In Progress | AppCore initialization specs landed; expand to additional page wiring and scroll hooks |
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ⚪ Not Started | Consider shared helpers for mocking API modules and storage |
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting |
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating |
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows |
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus |
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ✅ Complete | CI workflow runs Vitest and aggregates V8 coverage into `coverage/frontend` via a dedicated script |
## Next Steps Checklist
- [x] Expand unit tests for `storageHelpers` covering migrations and namespace behavior.
- [ ] Document DOM fixture strategy for reproducing template structures in tests.
- [x] Document DOM fixture strategy for reproducing template structures in tests.
- [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies.
- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
- [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures.
- [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios.
- [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4.
- [x] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
- [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added.
- [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
- [x] Implement LoRAs manager filtering/sorting specs for scenarios F-01F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize.
- [x] Implement checkpoints manager filtering/sorting specs for scenarios F-01F-05 & F-09; cover remaining paths alongside bulk action work.
- [x] Implement checkpoints page manager smoke tests covering initialization and duplicate badge wiring.
- [x] Outline focused checkpoints scenarios (filtering, sorting, duplicate badge toggles) to feed into the shared test matrix.
- [ ] Add duplicate badge regression coverage for zero/pending states after API refreshes.
Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress.

28
docs/library-switching.md Normal file
View File

@@ -0,0 +1,28 @@
# Library Switching and Preview Routes
Library switching no longer requires restarting the backend. The preview
thumbnails shown in the UI are now served through a dynamic endpoint that
resolves files against the folders registered for the active library at request
time. This allows the multi-library flow to update model roots without touching
the aiohttp router, so previews remain available immediately after a switch.
## How the dynamic preview endpoint works
* `config.get_preview_static_url()` now returns `/api/lm/previews?path=<encoded>`
for any preview path. The raw filesystem location is URL encoded so that it
can be passed through the query string without leaking directory structure in
the route itself.【F:py/config.py†L398-L404】
* `PreviewRoutes` exposes the `/api/lm/previews` handler which validates the
decoded path against the directories registered for the current library. The
request is rejected if it falls outside those roots or if the file does not
exist.【F:py/routes/preview_routes.py†L5-L21】【F:py/routes/handlers/preview_handlers.py†L9-L48】
* `Config` keeps an up-to-date cache of allowed preview roots. Every time a
library is applied the cache is rebuilt using the declared LoRA, checkpoint
and embedding directories (including symlink targets). The validation logic
checks preview requests against this cache.【F:py/config.py†L51-L68】【F:py/config.py†L180-L248】【F:py/config.py†L332-L346】
Both the ComfyUI runtime (`LoraManager.add_routes`) and the standalone launcher
(`StandaloneLoraManager.add_routes`) register the new preview routes instead of
mounting a static directory per root. Switching libraries therefore works
without restarting the application, and preview URLs generated before or after a
switch continue to resolve correctly.【F:py/lora_manager.py†L21-L82】【F:standalone.py†L302-L315】

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Bytes",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"openSettingsFileLocation": {
"label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen",
"success": "Einstellungsordner geöffnet",
"failed": "Einstellungsordner konnte nicht geöffnet werden"
},
"sections": {
"contentFiltering": "Inhaltsfilterung",
"videoSettings": "Video-Einstellungen",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "Aktive Bibliothek",
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.",
"loadingLibraries": "Bibliotheken werden geladen...",
"noLibraries": "Keine Bibliotheken konfiguriert",
"defaultLoraRoot": "Standard-LoRA-Stammordner",
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
@@ -382,6 +393,7 @@
"viewSelected": "Auswahl anzeigen",
"addTags": "Allen Tags hinzufügen",
"setBaseModel": "Basis-Modell für alle festlegen",
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"moveAll": "Alle in Ordner verschieben",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "Inhaltsbewertung festlegen",
"current": "Aktuell",
"multiple": "Mehrere Werte",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
"bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...",
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "Kompakt-Modus {state}",
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
"displayDensitySet": "Anzeige-Dichte auf {density} gesetzt",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Fehler beim Ändern der Sprache: {message}",
"cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.",
"cacheClearFailed": "Fehler beim Löschen des Caches: {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "Jetzt aktualisieren",
"refreshingIn": "Aktualisierung in",
"seconds": "Sekunden"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Bytes",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"openSettingsFileLocation": {
"label": "Open settings folder",
"tooltip": "Open the folder containing settings.json",
"success": "Opened settings.json folder",
"failed": "Failed to open settings.json folder"
},
"sections": {
"contentFiltering": "Content Filtering",
"videoSettings": "Video Settings",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "Active Library",
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
"loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured",
"defaultLoraRoot": "Default LoRA Root",
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
"defaultCheckpointRoot": "Default Checkpoint Root",
@@ -382,6 +393,7 @@
"viewSelected": "View Selected",
"addTags": "Add Tags to All",
"setBaseModel": "Set Base Model for All",
"setContentRating": "Set Content Rating for All",
"copyAll": "Copy All Syntax",
"refreshAll": "Refresh All Metadata",
"moveAll": "Move All to Folder",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "Set Content Rating",
"current": "Current",
"multiple": "Multiple values",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
"bulkContentRatingUpdating": "Updating content rating for {count} model(s)...",
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models",
"invalidCharactersRemoved": "Invalid characters removed from filename",
"filenameCannotBeEmpty": "File name cannot be empty",
"renameFailed": "Failed to rename file: {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "Compact Mode {state}",
"settingSaveFailed": "Failed to save setting: {message}",
"displayDensitySet": "Display Density set to {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Failed to change language: {message}",
"cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.",
"cacheClearFailed": "Failed to clear cache: {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "Refresh Now",
"refreshingIn": "Refreshing in",
"seconds": "seconds"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Bytes",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json",
"success": "Carpeta de settings.json abierta",
"failed": "No se pudo abrir la carpeta de settings.json"
},
"sections": {
"contentFiltering": "Filtrado de contenido",
"videoSettings": "Configuración de video",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "Biblioteca activa",
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.",
"loadingLibraries": "Cargando bibliotecas...",
"noLibraries": "No hay bibliotecas configuradas",
"defaultLoraRoot": "Raíz predeterminada de LoRA",
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
@@ -382,6 +393,7 @@
"viewSelected": "Ver seleccionados",
"addTags": "Añadir etiquetas a todos",
"setBaseModel": "Establecer modelo base para todos",
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"moveAll": "Mover todos a carpeta",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "Establecer clasificación de contenido",
"current": "Actual",
"multiple": "Valores múltiples",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
"bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...",
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
"renameFailed": "Error al renombrar archivo: {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "Modo compacto {state}",
"settingSaveFailed": "Error al guardar configuración: {message}",
"displayDensitySet": "Densidad de visualización establecida a {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Error al cambiar idioma: {message}",
"cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.",
"cacheClearFailed": "Error al limpiar caché: {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "Actualizar ahora",
"refreshingIn": "Actualizando en",
"seconds": "segundos"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Octets",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json",
"success": "Dossier settings.json ouvert",
"failed": "Impossible d'ouvrir le dossier settings.json"
},
"sections": {
"contentFiltering": "Filtrage du contenu",
"videoSettings": "Paramètres vidéo",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "Bibliothèque active",
"activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.",
"loadingLibraries": "Chargement des bibliothèques...",
"noLibraries": "Aucune bibliothèque configurée",
"defaultLoraRoot": "Racine LoRA par défaut",
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
@@ -382,6 +393,7 @@
"viewSelected": "Voir la sélection",
"addTags": "Ajouter des tags à tous",
"setBaseModel": "Définir le modèle de base pour tous",
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"moveAll": "Déplacer tout vers un dossier",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "Définir la classification du contenu",
"current": "Actuel",
"multiple": "Valeurs multiples",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
"bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...",
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
"renameFailed": "Échec du renommage du fichier : {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "Mode compact {state}",
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
"displayDensitySet": "Densité d'affichage définie sur {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Échec du changement de langue : {message}",
"cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.",
"cacheClearFailed": "Échec du vidage du cache : {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "Actualiser maintenant",
"refreshingIn": "Actualisation dans",
"seconds": "secondes"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

1267
locales/he.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0バイト",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"openSettingsFileLocation": {
"label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます",
"success": "settings.json フォルダーを開きました",
"failed": "settings.json フォルダーを開けませんでした"
},
"sections": {
"contentFiltering": "コンテンツフィルタリング",
"videoSettings": "動画設定",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "アクティブライブラリ",
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。",
"loadingLibraries": "ライブラリを読み込み中...",
"noLibraries": "ライブラリが設定されていません",
"defaultLoraRoot": "デフォルトLoRAルート",
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "デフォルトCheckpointルート",
@@ -382,6 +393,7 @@
"viewSelected": "選択中を表示",
"addTags": "すべてにタグを追加",
"setBaseModel": "すべてにベースモデルを設定",
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"moveAll": "すべてをフォルダに移動",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "コンテンツレーティングを設定",
"current": "現在",
"multiple": "複数の値",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
"bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...",
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
"renameFailed": "ファイル名の変更に失敗しました:{message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "コンパクトモード {state}",
"settingSaveFailed": "設定の保存に失敗しました:{message}",
"displayDensitySet": "表示密度が {density} に設定されました",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "言語の変更に失敗しました:{message}",
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "今すぐ更新",
"refreshingIn": "更新まで",
"seconds": "秒"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 바이트",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"openSettingsFileLocation": {
"label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다",
"success": "settings.json 폴더를 열었습니다",
"failed": "settings.json 폴더를 열지 못했습니다"
},
"sections": {
"contentFiltering": "콘텐츠 필터링",
"videoSettings": "비디오 설정",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "활성 라이브러리",
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.",
"loadingLibraries": "라이브러리를 불러오는 중...",
"noLibraries": "구성된 라이브러리가 없습니다",
"defaultLoraRoot": "기본 LoRA 루트",
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
"defaultCheckpointRoot": "기본 Checkpoint 루트",
@@ -382,6 +393,7 @@
"viewSelected": "선택 항목 보기",
"addTags": "모두에 태그 추가",
"setBaseModel": "모두에 베이스 모델 설정",
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"moveAll": "모두 폴더로 이동",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "콘텐츠 등급 설정",
"current": "현재",
"multiple": "여러 값",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
"bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...",
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
"renameFailed": "파일 이름 변경 실패: {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "컴팩트 모드 {state}",
"settingSaveFailed": "설정 저장 실패: {message}",
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "언어 변경 실패: {message}",
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
"cacheClearFailed": "캐시 지우기 실패: {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "지금 새로고침",
"refreshingIn": "새로고침까지",
"seconds": "초"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Байт",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"openSettingsFileLocation": {
"label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json",
"success": "Папка settings.json открыта",
"failed": "Не удалось открыть папку settings.json"
},
"sections": {
"contentFiltering": "Фильтрация контента",
"videoSettings": "Настройки видео",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "Активная библиотека",
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.",
"loadingLibraries": "Загрузка библиотек...",
"noLibraries": "Библиотеки не настроены",
"defaultLoraRoot": "Корневая папка LoRA по умолчанию",
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
@@ -382,6 +393,7 @@
"viewSelected": "Просмотреть выбранные",
"addTags": "Добавить теги ко всем",
"setBaseModel": "Установить базовую модель для всех",
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"moveAll": "Переместить все в папку",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "Установить рейтинг контента",
"current": "Текущий",
"multiple": "Несколько значений",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
"bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...",
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
"renameFailed": "Не удалось переименовать файл: {message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "Компактный режим {state}",
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
"displayDensitySet": "Плотность отображения установлена на {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Не удалось изменить язык: {message}",
"cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.",
"cacheClearFailed": "Не удалось очистить кэш: {error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "Обновить сейчас",
"refreshingIn": "Обновление через",
"seconds": "секунд"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -21,11 +21,18 @@
"disabled": "已禁用"
},
"language": {
"select": "Language",
"select_help": "Choose your preferred language for the interface",
"select": "选择语言",
"select_help": "选择你喜欢的界面语言",
"english": "English",
"chinese_simplified": "中文(简体)",
"chinese_traditional": "中文(繁体)",
"russian": "俄语",
"german": "德语",
"japanese": "日语",
"korean": "韩语",
"french": "法语",
"spanish": "西班牙语",
"Hebrew": "עברית",
"russian": "Русский",
"german": "Deutsch",
"japanese": "日本語",
@@ -187,6 +194,12 @@
"civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"openSettingsFileLocation": {
"label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹",
"success": "已打开 settings.json 文件夹",
"failed": "无法打开 settings.json 文件夹"
},
"sections": {
"contentFiltering": "内容过滤",
"videoSettings": "视频设置",
@@ -234,6 +247,10 @@
}
},
"folderSettings": {
"activeLibrary": "活动库",
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。",
"loadingLibraries": "正在加载库...",
"noLibraries": "尚未配置库",
"defaultLoraRoot": "默认 LoRA 根目录",
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
@@ -382,6 +399,7 @@
"viewSelected": "查看已选中",
"addTags": "为所有添加标签",
"setBaseModel": "为所有设置基础模型",
"setContentRating": "为全部设置内容评级",
"copyAll": "复制全部语法",
"refreshAll": "刷新全部元数据",
"moveAll": "全部移动到文件夹",
@@ -606,6 +624,7 @@
"contentRating": {
"title": "设置内容评级",
"current": "当前",
"multiple": "多个值",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1103,10 @@
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
"bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...",
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
"invalidCharactersRemoved": "文件名中的无效字符已移除",
"filenameCannotBeEmpty": "文件名不能为空",
"renameFailed": "重命名文件失败:{message}",
@@ -1118,6 +1141,8 @@
"compactModeToggled": "紧凑模式 {state}",
"settingSaveFailed": "保存设置失败:{message}",
"displayDensitySet": "显示密度已设置为 {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "切换语言失败:{message}",
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
"cacheClearFailed": "清除缓存失败:{error}",
@@ -1237,6 +1262,12 @@
"refreshNow": "立即刷新",
"refreshingIn": "将在",
"seconds": "秒后刷新"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -31,7 +31,8 @@
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 位元組",
@@ -187,6 +188,12 @@
"civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"openSettingsFileLocation": {
"label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾",
"success": "已開啟 settings.json 資料夾",
"failed": "無法開啟 settings.json 資料夾"
},
"sections": {
"contentFiltering": "內容過濾",
"videoSettings": "影片設定",
@@ -234,6 +241,10 @@
}
},
"folderSettings": {
"activeLibrary": "使用中的資料庫",
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。",
"loadingLibraries": "正在載入資料庫...",
"noLibraries": "尚未設定任何資料庫",
"defaultLoraRoot": "預設 LoRA 根目錄",
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
@@ -382,6 +393,7 @@
"viewSelected": "檢視已選取",
"addTags": "新增標籤到全部",
"setBaseModel": "設定全部基礎模型",
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"moveAll": "全部移動到資料夾",
@@ -606,6 +618,7 @@
"contentRating": {
"title": "設定內容分級",
"current": "目前",
"multiple": "多個值",
"levels": {
"pg": "PG",
"pg13": "PG13",
@@ -1084,6 +1097,10 @@
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
"bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...",
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
"invalidCharactersRemoved": "已移除檔名中的無效字元",
"filenameCannotBeEmpty": "檔案名稱不可為空",
"renameFailed": "重新命名檔案失敗:{message}",
@@ -1118,6 +1135,8 @@
"compactModeToggled": "緊湊模式已{state}",
"settingSaveFailed": "儲存設定失敗:{message}",
"displayDensitySet": "顯示密度已設為 {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "切換語言失敗:{message}",
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
"cacheClearFailed": "清除快取失敗:{error}",
@@ -1237,6 +1256,12 @@
"refreshNow": "立即重新整理",
"refreshingIn": "將於",
"seconds": "秒後重新整理"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
}
}
}

View File

@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "node scripts/run_frontend_coverage.js"
},
"devDependencies": {
"jsdom": "^24.0.0",

View File

@@ -1,16 +1,50 @@
import os
import platform
from pathlib import Path
import folder_paths # type: ignore
from typing import List
from typing import Dict, Iterable, List, Mapping, Set
import logging
import json
import urllib.parse
from .utils.settings_paths import ensure_settings_file
# Use an environment variable to control standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
logger = logging.getLogger(__name__)
def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]]
) -> Dict[str, Set[str]]:
"""Normalize folder paths for comparison across libraries."""
normalized: Dict[str, Set[str]] = {}
for key, values in folder_paths.items():
if isinstance(values, str):
candidate_values: Iterable[str] = [values]
else:
try:
candidate_values = iter(values)
except TypeError:
continue
normalized_values: Set[str] = set()
for value in candidate_values:
if not isinstance(value, str):
continue
stripped = value.strip()
if not stripped:
continue
normalized_values.add(os.path.normcase(os.path.normpath(stripped)))
if normalized_values:
normalized[key] = normalized_values
return normalized
class Config:
"""Global configuration for LoRA Manager"""
@@ -19,9 +53,9 @@ class Config:
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
# Path mapping dictionary, target to link mapping
self._path_mappings = {}
# Static route mapping dictionary, target to route mapping
self._route_mappings = {}
self._path_mappings: Dict[str, str] = {}
# Normalized preview root directories used to validate preview access
self._preview_root_paths: Set[Path] = set()
self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = None
self.unet_roots = None
@@ -30,45 +64,73 @@ class Config:
self.embeddings_roots = self._init_embedding_paths()
# Scan symbolic links during initialization
self._scan_symbolic_links()
self._rebuild_preview_roots()
if not standalone_mode:
# Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings()
def save_folder_paths_to_settings(self):
"""Save folder paths to settings.json for standalone mode to use later"""
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
try:
# Check if we're running in ComfyUI mode (not standalone)
# Load existing settings
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json')
settings = {}
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
# Update settings with paths
settings['folder_paths'] = {
'loras': self.loras_roots,
'checkpoints': self.checkpoints_roots,
'unet': self.unet_roots,
'embeddings': self.embeddings_roots,
}
# Add default roots if there's only one item and key doesn't exist
if len(self.loras_roots) == 1 and "default_lora_root" not in settings:
settings["default_lora_root"] = self.loras_roots[0]
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
settings["default_checkpoint_root"] = self.checkpoints_roots[0]
ensure_settings_file(logger)
from .services.settings_manager import settings as settings_service
if self.embeddings_roots and len(self.embeddings_roots) == 1 and "default_embedding_root" not in settings:
settings["default_embedding_root"] = self.embeddings_roots[0]
# Save settings
with open(settings_path, 'w', encoding='utf-8') as f:
json.dump(settings, f, indent=2)
logger.info("Saved folder paths to settings.json")
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {})
target_folder_paths = {
'loras': list(self.loras_roots),
'checkpoints': list(self.checkpoints_roots or []),
'unet': list(self.unet_roots or []),
'embeddings': list(self.embeddings_roots or []),
}
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
if (not comfy_library and default_library and normalized_target_paths and
_normalize_folder_paths_for_comparison(default_library.get("folder_paths", {})) ==
normalized_target_paths):
try:
settings_service.rename_library("default", "comfyui")
logger.info("Renamed legacy 'default' library to 'comfyui'")
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
except Exception as rename_error:
logger.debug(
"Failed to rename legacy 'default' library: %s", rename_error
)
default_lora_root = comfy_library.get("default_lora_root", "")
if not default_lora_root and len(self.loras_roots) == 1:
default_lora_root = self.loras_roots[0]
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
if (not default_checkpoint_root and self.checkpoints_roots and
len(self.checkpoints_roots) == 1):
default_checkpoint_root = self.checkpoints_roots[0]
default_embedding_root = comfy_library.get("default_embedding_root", "")
if (not default_embedding_root and self.embeddings_roots and
len(self.embeddings_roots) == 1):
default_embedding_root = self.embeddings_roots[0]
metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI")
metadata["source"] = "comfyui"
settings_service.upsert_library(
"comfyui",
folder_paths=target_folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_embedding_root=default_embedding_root,
metadata=metadata,
activate=True,
)
logger.info("Updated 'comfyui' library with current folder paths")
except Exception as e:
logger.warning(f"Failed to save folder paths: {e}")
@@ -125,12 +187,65 @@ class Config:
# Keep the original mapping: target path -> link path
self._path_mappings[normalized_target] = normalized_link
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
def add_route_mapping(self, path: str, route: str):
"""Add a static route mapping"""
normalized_path = os.path.normpath(path).replace(os.sep, '/')
self._route_mappings[normalized_path] = route
# logger.info(f"Added route mapping: {normalized_path} -> {route}")
def _expand_preview_root(self, path: str) -> Set[Path]:
"""Return normalized ``Path`` objects representing a preview root."""
roots: Set[Path] = set()
if not path:
return roots
try:
raw_path = Path(path).expanduser()
except Exception:
return roots
if raw_path.is_absolute():
roots.add(raw_path)
try:
resolved = raw_path.resolve(strict=False)
except RuntimeError:
resolved = raw_path.absolute()
roots.add(resolved)
try:
real_path = raw_path.resolve()
except (FileNotFoundError, RuntimeError):
real_path = resolved
roots.add(real_path)
normalized: Set[Path] = set()
for candidate in roots:
if candidate.is_absolute():
normalized.add(candidate)
else:
try:
normalized.add(candidate.resolve(strict=False))
except RuntimeError:
normalized.add(candidate.absolute())
return normalized
def _rebuild_preview_roots(self) -> None:
"""Recompute the cache of directories permitted for previews."""
preview_roots: Set[Path] = set()
for root in self.loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.base_models_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root))
for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path"""
@@ -154,31 +269,93 @@ class Config:
return mapped_path
return link_path
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
dedup: Dict[str, str] = {}
for path in raw_paths:
if not isinstance(path, str):
continue
if not os.path.exists(path):
continue
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
normalized = os.path.normpath(path).replace(os.sep, '/')
if real_path not in dedup:
dedup[real_path] = normalized
return dedup
def _prepare_lora_paths(self, raw_paths: Iterable[str]) -> List[str]:
path_map = self._dedupe_existing_paths(raw_paths)
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> List[str]:
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
unet_map = self._dedupe_existing_paths(unet_paths)
merged_map: Dict[str, str] = {}
for real_path, original in {**checkpoint_map, **unet_map}.items():
if real_path not in merged_map:
merged_map[real_path] = original
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
checkpoint_values = set(checkpoint_map.values())
unet_values = set(unet_map.values())
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
self.unet_roots = [p for p in unique_paths if p in unet_values]
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
path_map = self._dedupe_existing_paths(raw_paths)
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
def _apply_library_paths(self, folder_paths: Mapping[str, Iterable[str]]) -> None:
self._path_mappings.clear()
self._preview_root_paths = set()
lora_paths = folder_paths.get('loras', []) or []
checkpoint_paths = folder_paths.get('checkpoints', []) or []
unet_paths = folder_paths.get('unet', []) or []
embedding_paths = folder_paths.get('embeddings', []) or []
self.loras_roots = self._prepare_lora_paths(lora_paths)
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
self._scan_symbolic_links()
self._rebuild_preview_roots()
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("loras")
# Normalize and resolve symlinks, store mapping from resolved -> original
path_map = {}
for path in raw_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
# Now sort and use only the deduplicated real paths
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
unique_paths = self._prepare_lora_paths(raw_paths)
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid loras folders found in ComfyUI configuration")
return []
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
except Exception as e:
logger.warning(f"Error initializing LoRA paths: {e}")
@@ -187,52 +364,17 @@ class Config:
def _init_checkpoint_paths(self) -> List[str]:
"""Initialize and validate checkpoint paths from ComfyUI settings"""
try:
# Get checkpoint paths from folder_paths
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet")
# Normalize and resolve symlinks for checkpoints, store mapping from resolved -> original
checkpoint_map = {}
for path in raw_checkpoint_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
checkpoint_map[real_path] = checkpoint_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
# Normalize and resolve symlinks for unet, store mapping from resolved -> original
unet_map = {}
for path in raw_unet_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
# Merge both maps and deduplicate by real path
merged_map = {}
for real_path, orig_path in {**checkpoint_map, **unet_map}.items():
if real_path not in merged_map:
merged_map[real_path] = orig_path
unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
# Now sort and use only the deduplicated real paths
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
# Split back into checkpoints and unet roots for class properties
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
self.unet_roots = [p for p in unique_paths if p in unet_map.values()]
all_paths = unique_paths
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
if not all_paths:
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
return []
# Initialize path mappings
for original_path in all_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return all_paths
return unique_paths
except Exception as e:
logger.warning(f"Error initializing checkpoint paths: {e}")
return []
@@ -241,27 +383,13 @@ class Config:
"""Initialize and validate embedding paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("embeddings")
# Normalize and resolve symlinks, store mapping from resolved -> original
path_map = {}
for path in raw_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
# Now sort and use only the deduplicated real paths
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid embeddings folders found in ComfyUI configuration")
return []
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
except Exception as e:
logger.warning(f"Error initializing embedding paths: {e}")
@@ -270,25 +398,61 @@ class Config:
def get_preview_static_url(self, preview_path: str) -> str:
if not preview_path:
return ""
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
# Find longest matching path (most specific match)
best_match = ""
best_route = ""
for path, route in self._route_mappings.items():
if real_path.startswith(path) and len(path) > len(best_match):
best_match = path
best_route = route
if best_match:
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
safe_path = '/'.join(safe_parts)
return f'{best_route}/{safe_path}'
return ""
normalized = os.path.normpath(preview_path).replace(os.sep, '/')
encoded_path = urllib.parse.quote(normalized, safe='')
return f'/api/lm/previews?path={encoded_path}'
def is_preview_path_allowed(self, preview_path: str) -> bool:
"""Return ``True`` if ``preview_path`` is within an allowed directory."""
if not preview_path:
return False
try:
candidate = Path(preview_path).expanduser().resolve(strict=False)
except Exception:
return False
for root in self._preview_root_paths:
try:
candidate.relative_to(root)
return True
except ValueError:
continue
return False
def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
"""Update runtime paths to match the provided library configuration."""
folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {}
if not isinstance(folder_paths, Mapping):
folder_paths = {}
self._apply_library_paths(folder_paths)
logger.info(
"Applied library settings with %d lora roots, %d checkpoint roots, and %d embedding roots",
len(self.loras_roots or []),
len(self.base_models_roots or []),
len(self.embeddings_roots or []),
)
def get_library_registry_snapshot(self) -> Dict[str, object]:
"""Return the current library registry and active library name."""
try:
from .services.settings_manager import settings as settings_service
libraries = settings_service.get_libraries()
active_library = settings_service.get_active_library_name()
return {
"active_library": active_library,
"libraries": libraries,
}
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to collect library registry snapshot: %s", exc)
return {"active_library": "", "libraries": {}}
# Global config instance
config = Config()

View File

@@ -2,7 +2,6 @@ import asyncio
import sys
import os
import logging
from pathlib import Path
from server import PromptServer # type: ignore
from .config import config
@@ -11,6 +10,7 @@ from .routes.recipe_routes import RecipeRoutes
from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.preview_routes import PreviewRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings
@@ -50,102 +50,12 @@ class LoraManager:
asyncio_logger = logging.getLogger("asyncio")
asyncio_logger.addFilter(ConnectionResetFilter())
added_targets = set() # Track already added target paths
# Add static route for example images if the path exists in settings
example_images_path = settings.get('example_images_path')
logger.info(f"Example images path: {example_images_path}")
if example_images_path and os.path.exists(example_images_path):
app.router.add_static('/example_images_static', example_images_path)
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
# Add static routes for each lora root
for idx, root in enumerate(config.loras_roots, start=1):
preview_path = f'/loras_static/root{idx}/preview'
real_root = root
if root in config._path_mappings.values():
for target, link in config._path_mappings.items():
if link == root:
real_root = target
break
# Add static route for original path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {real_root}")
# Record route mapping
config.add_route_mapping(real_root, preview_path)
added_targets.add(real_root)
# Add static routes for each checkpoint root
for idx, root in enumerate(config.base_models_roots, start=1):
preview_path = f'/checkpoints_static/root{idx}/preview'
real_root = root
if root in config._path_mappings.values():
for target, link in config._path_mappings.items():
if link == root:
real_root = target
break
# Add static route for original path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {real_root}")
# Record route mapping
config.add_route_mapping(real_root, preview_path)
added_targets.add(real_root)
# Add static routes for each embedding root
for idx, root in enumerate(config.embeddings_roots, start=1):
preview_path = f'/embeddings_static/root{idx}/preview'
real_root = root
if root in config._path_mappings.values():
for target, link in config._path_mappings.items():
if link == root:
real_root = target
break
# Add static route for original path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {real_root}")
# Record route mapping
config.add_route_mapping(real_root, preview_path)
added_targets.add(real_root)
# Add static routes for symlink target paths
link_idx = {
'lora': 1,
'checkpoint': 1,
'embedding': 1
}
for target_path, link_path in config._path_mappings.items():
if target_path not in added_targets:
# Determine if this is a checkpoint, lora, or embedding link based on path
is_checkpoint = any(cp_root in link_path for cp_root in config.base_models_roots)
is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.base_models_roots)
is_embedding = any(emb_root in link_path for emb_root in config.embeddings_roots)
is_embedding = is_embedding or any(emb_root in target_path for emb_root in config.embeddings_roots)
if is_checkpoint:
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
link_idx["checkpoint"] += 1
elif is_embedding:
route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview'
link_idx["embedding"] += 1
else:
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
link_idx["lora"] += 1
try:
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
logger.info(f"Added static route for link target {route_path} -> {target_path}")
config.add_route_mapping(target_path, route_path)
added_targets.add(target_path)
except Exception as e:
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
@@ -168,6 +78,7 @@ class LoraManager:
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)

View File

@@ -1,7 +1,7 @@
import os
# Check if running in standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
if not standalone_mode:
from .metadata_hook import MetadataHook

View File

@@ -3,7 +3,7 @@ import os
from .constants import IMAGES
# Check if running in standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER

View File

@@ -284,7 +284,59 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
result["loras"].append(lora_entry)
# If we found LoRA hashes in the metadata but haven't already
# populated entries for them, fall back to creating LoRAs from
# the hashes section. Some Civitai image responses only include
# LoRA information here without explicit resources entries.
for lora_name, lora_hash in lora_hashes.items():
if not lora_hash:
continue
# Skip LoRAs we've already added via resources or other fields
if lora_hash in added_loras:
continue
lora_entry = {
'name': lora_name,
'type': "lora",
'weight': 1.0,
'hash': lora_hash,
'existsLocally': False,
'localPath': None,
'file_name': lora_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if metadata_provider:
try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash
)
if populated_entry is None:
continue
lora_entry = populated_entry
if 'id' in lora_entry and lora_entry['id']:
added_loras[str(lora_entry['id'])] = len(result["loras"])
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}")
added_loras[lora_hash] = len(result["loras"])
result["loras"].append(lora_entry)
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
lora_index = 0
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:

View File

@@ -134,7 +134,7 @@ class BaseRecipeRoutes:
recipe_scanner_getter = lambda: self.recipe_scanner
civitai_client_getter = lambda: self.civitai_client
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
if not standalone_mode:
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]

View File

@@ -0,0 +1,795 @@
"""Handlers for miscellaneous routes.
The legacy :mod:`py.routes.misc_routes` module bundled HTTP wiring and
business logic in a single class. This module mirrors the model route
architecture by splitting the responsibilities into dedicated handler
objects that can be composed by the route controller.
"""
from __future__ import annotations
import asyncio
import logging
import os
import subprocess
import sys
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Mapping, Protocol
from aiohttp import web
from ...config import config
from ...services.metadata_service import (
get_metadata_archive_manager,
update_metadata_providers,
)
from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import settings as default_settings
from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader
from ...utils.constants import DEFAULT_NODE_COLOR, NODE_TYPES, SUPPORTED_MEDIA_EXTENSIONS
from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.lora_metadata import extract_trained_words
from ...utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol"
def send_sync(self, event: str, payload: dict) -> None: # pragma: no cover - protocol
...
class DownloaderProtocol(Protocol):
async def refresh_session(self) -> None: # pragma: no cover - protocol
...
class UsageStatsFactory(Protocol):
def __call__(self) -> UsageStats: # pragma: no cover - protocol
...
class MetadataProviderProtocol(Protocol):
async def get_model_versions(self, model_id: int) -> dict | None: # pragma: no cover - protocol
...
class MetadataArchiveManagerProtocol(Protocol):
async def download_and_extract_database(
self, progress_callback: Callable[[str, str], None]
) -> bool: # pragma: no cover - protocol
...
async def remove_database(self) -> bool: # pragma: no cover - protocol
...
def is_database_available(self) -> bool: # pragma: no cover - protocol
...
def get_database_path(self) -> str | None: # pragma: no cover - protocol
...
class NodeRegistry:
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._nodes: Dict[int, dict] = {}
self._registry_updated = asyncio.Event()
async def register_nodes(self, nodes: list[dict]) -> None:
async with self._lock:
self._nodes.clear()
for node in nodes:
node_id = node["node_id"]
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
self._nodes[node_id] = {
"id": node_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
}
logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set()
async def get_registry(self) -> dict:
async with self._lock:
return {
"nodes": dict(self._nodes),
"node_count": len(self._nodes),
}
async def wait_for_update(self, timeout: float = 1.0) -> bool:
self._registry_updated.clear()
try:
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
class HealthCheckHandler:
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
class SettingsHandler:
"""Sync settings between backend and frontend."""
_SYNC_KEYS = (
"civitai_api_key",
"default_lora_root",
"default_checkpoint_root",
"default_embedding_root",
"base_model_path_mappings",
"download_path_templates",
"enable_metadata_archive_db",
"language",
"proxy_enabled",
"proxy_type",
"proxy_host",
"proxy_port",
"proxy_username",
"proxy_password",
"example_images_path",
"optimize_example_images",
"auto_download_example_images",
"blur_mature_content",
"autoplay_on_hover",
"display_density",
"card_info_display",
"include_trigger_words",
"show_only_sfw",
"compact_mode",
)
_PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"}
def __init__(
self,
*,
settings_service=default_settings,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
downloader_factory: Callable[[], Awaitable[DownloaderProtocol]] = get_downloader,
) -> None:
self._settings = settings_service
self._metadata_provider_updater = metadata_provider_updater
self._downloader_factory = downloader_factory
async def get_libraries(self, request: web.Request) -> web.Response:
"""Return the registered libraries and the active selection."""
try:
snapshot = config.get_library_registry_snapshot()
libraries = snapshot.get("libraries", {})
active_library = snapshot.get("active_library", "")
return web.json_response(
{
"success": True,
"libraries": libraries,
"active_library": active_library,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting library registry: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_settings(self, request: web.Request) -> web.Response:
try:
response_data = {}
for key in self._SYNC_KEYS:
value = self._settings.get(key)
if value is not None:
response_data[key] = value
return web.json_response({"success": True, "settings": response_data})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting settings: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def activate_library(self, request: web.Request) -> web.Response:
"""Activate the selected library."""
try:
data = await request.json()
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error parsing activate library request: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
library_name = data.get("library") or data.get("library_name")
if not isinstance(library_name, str) or not library_name.strip():
return web.json_response(
{"success": False, "error": "Library name is required"}, status=400
)
try:
normalized_name = library_name.strip()
self._settings.activate_library(normalized_name)
snapshot = config.get_library_registry_snapshot()
libraries = snapshot.get("libraries", {})
active_library = snapshot.get("active_library", "")
return web.json_response(
{
"success": True,
"active_library": active_library,
"libraries": libraries,
}
)
except KeyError as exc:
logger.debug("Attempted to activate unknown library '%s'", library_name)
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error activating library '%s': %s", library_name, exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_settings(self, request: web.Request) -> web.Response:
try:
data = await request.json()
proxy_changed = False
for key, value in data.items():
if value == self._settings.get(key):
continue
if key == "example_images_path" and value:
validation_error = self._validate_example_images_path(value)
if validation_error:
return web.json_response({"success": False, "error": validation_error})
if value == "__DELETE__" and key in ("proxy_username", "proxy_password"):
self._settings.delete(key)
else:
self._settings.set(key, value)
if key == "enable_metadata_archive_db":
await self._metadata_provider_updater()
if key in self._PROXY_KEYS:
proxy_changed = True
if proxy_changed:
downloader = await self._downloader_factory()
await downloader.refresh_session()
return web.json_response({"success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error updating settings: %s", exc, exc_info=True)
return web.Response(status=500, text=str(exc))
def _validate_example_images_path(self, folder_path: str) -> str | None:
if not os.path.exists(folder_path):
return f"Path does not exist: {folder_path}"
if not os.path.isdir(folder_path):
return "Please set a dedicated folder for example images."
if not self._is_dedicated_example_images_folder(folder_path):
return "Please set a dedicated folder for example images."
return None
def _is_dedicated_example_images_folder(self, folder_path: str) -> bool:
return is_valid_example_images_root(folder_path)
class UsageStatsHandler:
def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None:
self._usage_stats_factory = usage_stats_factory
async def update_usage_stats(self, request: web.Request) -> web.Response:
try:
data = await request.json()
prompt_id = data.get("prompt_id")
if not prompt_id:
return web.json_response({"success": False, "error": "Missing prompt_id"}, status=400)
usage_stats = self._usage_stats_factory()
await usage_stats.process_execution(prompt_id)
return web.json_response({"success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update usage stats: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_usage_stats(self, request: web.Request) -> web.Response:
try:
usage_stats = self._usage_stats_factory()
stats = await usage_stats.get_stats()
stats_response = {"success": True, "data": stats, "format_version": 2}
return web.json_response(stats_response)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get usage stats: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class LoraCodeHandler:
def __init__(self, prompt_server: type[PromptServerProtocol]) -> None:
self._prompt_server = prompt_server
async def update_lora_code(self, request: web.Request) -> web.Response:
try:
data = await request.json()
node_ids = data.get("node_ids")
lora_code = data.get("lora_code", "")
mode = data.get("mode", "append")
if not lora_code:
return web.json_response({"success": False, "error": "Missing lora_code parameter"}, status=400)
results = []
if node_ids is None:
try:
self._prompt_server.instance.send_sync(
"lora_code_update", {"id": -1, "lora_code": lora_code, "mode": mode}
)
results.append({"node_id": "broadcast", "success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error broadcasting lora code: %s", exc)
results.append({"node_id": "broadcast", "success": False, "error": str(exc)})
else:
for node_id in node_ids:
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
{"id": node_id, "lora_code": lora_code, "mode": mode},
)
results.append({"node_id": node_id, "success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error sending lora code to node %s: %s", node_id, exc)
results.append({"node_id": node_id, "success": False, "error": str(exc)})
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update lora code: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class TrainedWordsHandler:
async def get_trained_words(self, request: web.Request) -> web.Response:
try:
file_path = request.query.get("file_path")
if not file_path:
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400)
if not os.path.exists(file_path):
return web.json_response({"success": False, "error": "File not found"}, status=404)
if not file_path.endswith(".safetensors"):
return web.json_response({"success": False, "error": "File must be a safetensors file"}, status=400)
trained_words, class_tokens = await extract_trained_words(file_path)
return web.json_response(
{
"success": True,
"trained_words": trained_words,
"class_tokens": class_tokens,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get trained words: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelExampleFilesHandler:
async def get_model_example_files(self, request: web.Request) -> web.Response:
try:
model_path = request.query.get("model_path")
if not model_path:
return web.json_response({"success": False, "error": "Missing model_path parameter"}, status=400)
model_dir = os.path.dirname(model_path)
if not os.path.exists(model_dir):
return web.json_response({"success": False, "error": "Model directory not found"}, status=404)
base_name = os.path.splitext(os.path.basename(model_path))[0]
files = []
pattern = f"{base_name}.example."
for file in os.listdir(model_dir):
if not file.startswith(pattern):
continue
file_full_path = os.path.join(model_dir, file)
if not os.path.isfile(file_full_path):
continue
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in SUPPORTED_MEDIA_EXTENSIONS["images"] and file_ext not in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
continue
try:
index = int(file[len(pattern) :].split(".")[0])
except (ValueError, IndexError):
index = float("inf")
static_url = config.get_preview_static_url(file_full_path)
files.append(
{
"name": file,
"path": static_url,
"extension": file_ext,
"is_video": file_ext in SUPPORTED_MEDIA_EXTENSIONS["videos"],
"index": index,
}
)
files.sort(key=lambda item: item["index"])
for file in files:
file.pop("index", None)
return web.json_response({"success": True, "files": files})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get model example files: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@dataclass
class ServiceRegistryAdapter:
get_lora_scanner: Callable[[], Awaitable]
get_checkpoint_scanner: Callable[[], Awaitable]
get_embedding_scanner: Callable[[], Awaitable]
class ModelLibraryHandler:
def __init__(self, service_registry: ServiceRegistryAdapter, metadata_provider_factory: Callable[[], Awaitable[MetadataProviderProtocol | None]]) -> None:
self._service_registry = service_registry
self._metadata_provider_factory = metadata_provider_factory
async def check_model_exists(self, request: web.Request) -> web.Response:
try:
model_id_str = request.query.get("modelId")
model_version_id_str = request.query.get("modelVersionId")
if not model_id_str:
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400)
try:
model_id = int(model_id_str)
except ValueError:
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
if model_version_id_str:
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response({"success": False, "error": "Parameter modelVersionId must be an integer"}, status=400)
exists = False
model_type = None
if await lora_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = "lora"
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = "checkpoint"
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = "embedding"
return web.json_response({"success": True, "exists": exists, "modelType": model_type if exists else None})
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
checkpoint_versions = []
embedding_versions = []
if not lora_versions and checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
if not lora_versions and not checkpoint_versions and embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
model_type = None
versions = []
if lora_versions:
model_type = "lora"
versions = lora_versions
elif checkpoint_versions:
model_type = "checkpoint"
versions = checkpoint_versions
elif embedding_versions:
model_type = "embedding"
versions = embedding_versions
return web.json_response({"success": True, "modelType": model_type, "versions": versions})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response:
try:
model_id_str = request.query.get("modelId")
if not model_id_str:
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400)
try:
model_id = int(model_id_str)
except ValueError:
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400)
metadata_provider = await self._metadata_provider_factory()
if not metadata_provider:
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
response = await metadata_provider.get_model_versions(model_id)
if not response or not response.get("modelVersions"):
return web.json_response({"success": False, "error": "Model not found"}, status=404)
versions = response.get("modelVersions", [])
model_name = response.get("name", "")
model_type = response.get("type", "").lower()
scanner = None
normalized_type = None
if model_type in {"lora", "locon", "dora"}:
scanner = await self._service_registry.get_lora_scanner()
normalized_type = "lora"
elif model_type == "checkpoint":
scanner = await self._service_registry.get_checkpoint_scanner()
normalized_type = "checkpoint"
elif model_type == "textualinversion":
scanner = await self._service_registry.get_embedding_scanner()
normalized_type = "embedding"
else:
return web.json_response({"success": False, "error": f'Model type "{model_type}" is not supported'}, status=400)
if not scanner:
return web.json_response({"success": False, "error": f'Scanner for type "{normalized_type}" is not available'}, status=503)
local_versions = await scanner.get_model_versions_by_id(model_id)
local_version_ids = {version["versionId"] for version in local_versions}
enriched_versions = []
for version in versions:
version_id = version.get("id")
enriched_versions.append(
{
"id": version_id,
"name": version.get("name", ""),
"thumbnailUrl": version.get("images")[0]["url"] if version.get("images") else None,
"inLibrary": version_id in local_version_ids,
}
)
return web.json_response(
{
"success": True,
"modelId": model_id,
"modelName": model_name,
"modelType": model_type,
"versions": enriched_versions,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get model versions status: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MetadataArchiveHandler:
def __init__(
self,
*,
metadata_archive_manager_factory: Callable[[], Awaitable[MetadataArchiveManagerProtocol]] = get_metadata_archive_manager,
settings_service=default_settings,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
) -> None:
self._metadata_archive_manager_factory = metadata_archive_manager_factory
self._settings = settings_service
self._metadata_provider_updater = metadata_provider_updater
async def download_metadata_archive(self, request: web.Request) -> web.Response:
try:
archive_manager = await self._metadata_archive_manager_factory()
download_id = request.query.get("download_id")
def progress_callback(stage: str, message: str) -> None:
data = {"stage": stage, "message": message, "type": "metadata_archive_download"}
if download_id:
asyncio.create_task(ws_manager.broadcast_download_progress(download_id, data))
else:
asyncio.create_task(ws_manager.broadcast(data))
success = await archive_manager.download_and_extract_database(progress_callback)
if success:
self._settings.set("enable_metadata_archive_db", True)
await self._metadata_provider_updater()
return web.json_response({"success": True, "message": "Metadata archive database downloaded and extracted successfully"})
return web.json_response({"success": False, "error": "Failed to download and extract metadata archive database"}, status=500)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error downloading metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def remove_metadata_archive(self, request: web.Request) -> web.Response:
try:
archive_manager = await self._metadata_archive_manager_factory()
success = await archive_manager.remove_database()
if success:
self._settings.set("enable_metadata_archive_db", False)
await self._metadata_provider_updater()
return web.json_response({"success": True, "message": "Metadata archive database removed successfully"})
return web.json_response({"success": False, "error": "Failed to remove metadata archive database"}, status=500)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error removing metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_metadata_archive_status(self, request: web.Request) -> web.Response:
try:
archive_manager = await self._metadata_archive_manager_factory()
is_available = archive_manager.is_database_available()
is_enabled = self._settings.get("enable_metadata_archive_db", False)
db_size = 0
if is_available:
db_path = archive_manager.get_database_path()
if db_path and os.path.exists(db_path):
db_size = os.path.getsize(db_path)
return web.json_response(
{
"success": True,
"isAvailable": is_available,
"isEnabled": is_enabled,
"databaseSize": db_size,
"databasePath": archive_manager.get_database_path() if is_available else None,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting metadata archive status: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class FileSystemHandler:
async def open_file_location(self, request: web.Request) -> web.Response:
try:
data = await request.json()
file_path = data.get("file_path")
if not file_path:
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400)
file_path = os.path.abspath(file_path)
if not os.path.isfile(file_path):
return web.json_response({"success": False, "error": "File does not exist"}, status=404)
if os.name == "nt":
subprocess.Popen(["explorer", "/select,", file_path])
elif os.name == "posix":
if sys.platform == "darwin":
subprocess.Popen(["open", "-R", file_path])
else:
folder = os.path.dirname(file_path)
subprocess.Popen(["xdg-open", folder])
return web.json_response({"success": True, "message": f"Opened folder and selected file: {file_path}"})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open file location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class NodeRegistryHandler:
def __init__(
self,
node_registry: NodeRegistry,
prompt_server: type[PromptServerProtocol],
*,
standalone_mode: bool,
) -> None:
self._node_registry = node_registry
self._prompt_server = prompt_server
self._standalone_mode = standalone_mode
async def register_nodes(self, request: web.Request) -> web.Response:
try:
data = await request.json()
nodes = data.get("nodes", [])
if not isinstance(nodes, list):
return web.json_response({"success": False, "error": "nodes must be a list"}, status=400)
for index, node in enumerate(nodes):
if not isinstance(node, dict):
return web.json_response({"success": False, "error": f"Node {index} must be an object"}, status=400)
node_id = node.get("node_id")
if node_id is None:
return web.json_response({"success": False, "error": f"Node {index} missing node_id parameter"}, status=400)
try:
node["node_id"] = int(node_id)
except (TypeError, ValueError):
return web.json_response({"success": False, "error": f"Node {index} node_id must be an integer"}, status=400)
await self._node_registry.register_nodes(nodes)
return web.json_response({"success": True, "message": f"{len(nodes)} nodes registered successfully"})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to register nodes: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_registry(self, request: web.Request) -> web.Response:
try:
if self._standalone_mode:
logger.warning("Registry refresh not available in standalone mode")
return web.json_response(
{
"success": False,
"error": "Standalone Mode Active",
"message": "Cannot interact with ComfyUI in standalone mode.",
},
status=503,
)
try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug("Sent registry refresh request to frontend")
except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response(
{
"success": False,
"error": "Communication Error",
"message": f"Failed to communicate with ComfyUI frontend: {exc}",
},
status=500,
)
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
if not registry_updated:
logger.warning("Registry refresh timeout after 1 second")
return web.json_response(
{
"success": False,
"error": "Timeout Error",
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
},
status=408,
)
registry_info = await self._node_registry.get_registry()
return web.json_response({"success": True, "data": registry_info})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get registry: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": "Internal Error", "message": str(exc)}, status=500)
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
def __init__(
self,
*,
health: HealthCheckHandler,
settings: SettingsHandler,
usage_stats: UsageStatsHandler,
lora_code: LoraCodeHandler,
trained_words: TrainedWordsHandler,
model_examples: ModelExampleFilesHandler,
node_registry: NodeRegistryHandler,
model_library: ModelLibraryHandler,
metadata_archive: MetadataArchiveHandler,
filesystem: FileSystemHandler,
) -> None:
self.health = health
self.settings = settings
self.usage_stats = usage_stats
self.lora_code = lora_code
self.trained_words = trained_words
self.model_examples = model_examples
self.node_registry = node_registry
self.model_library = model_library
self.metadata_archive = metadata_archive
self.filesystem = filesystem
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
return {
"health_check": self.health.health_check,
"get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings,
"get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library,
"update_usage_stats": self.usage_stats.update_usage_stats,
"get_usage_stats": self.usage_stats.get_usage_stats,
"update_lora_code": self.lora_code.update_lora_code,
"get_trained_words": self.trained_words.get_trained_words,
"get_model_example_files": self.model_examples.get_model_example_files,
"register_nodes": self.node_registry.register_nodes,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
"get_model_versions_status": self.model_library.get_model_versions_status,
"open_file_location": self.filesystem.open_file_location,
}
def build_service_registry_adapter() -> ServiceRegistryAdapter:
return ServiceRegistryAdapter(
get_lora_scanner=ServiceRegistry.get_lora_scanner,
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
)

View File

@@ -0,0 +1,56 @@
"""Handlers responsible for serving preview assets dynamically."""
from __future__ import annotations
import logging
import urllib.parse
from pathlib import Path
from aiohttp import web
from ...config import config as global_config
logger = logging.getLogger(__name__)
class PreviewHandler:
"""Serve preview assets for the active library at request time."""
def __init__(self, *, config=global_config) -> None:
self._config = config
async def serve_preview(self, request: web.Request) -> web.StreamResponse:
"""Return the preview file referenced by the encoded ``path`` query."""
raw_path = request.query.get("path", "")
if not raw_path:
raise web.HTTPBadRequest(text="Missing 'path' query parameter")
try:
decoded_path = urllib.parse.unquote(raw_path)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to decode preview path %s: %s", raw_path, exc)
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
normalized = decoded_path.replace("\\", "/")
candidate = Path(normalized)
try:
resolved = candidate.expanduser().resolve(strict=False)
except Exception as exc:
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
resolved_str = str(resolved)
if not self._config.is_preview_path_allowed(resolved_str):
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
if not resolved.is_file():
logger.debug("Preview file not found at %s", resolved_str)
raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests and content headers for us.
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
__all__ = ["PreviewHandler"]

View File

@@ -206,18 +206,16 @@ class RecipeListingHandler:
def format_recipe_file_url(self, file_path: str) -> str:
try:
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, "/")
normalized_path = file_path.replace(os.sep, "/")
if normalized_path.startswith(recipes_dir):
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, "/")
return f"/loras_static/root1/preview/{relative_path}"
file_name = os.path.basename(file_path)
return f"/loras_static/root1/preview/recipes/{file_name}"
normalized_path = os.path.normpath(file_path)
static_url = config.get_preview_static_url(normalized_path)
if static_url:
return static_url
except Exception as exc: # pragma: no cover - logging path
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
return "/loras_static/images/no-preview.png"
return "/loras_static/images/no-preview.png"
class RecipeQueryHandler:
"""Provide read-only insights on recipe data."""

View File

@@ -0,0 +1,69 @@
"""Route registrar for miscellaneous endpoints.
This module mirrors the model route registrar architecture so that
miscellaneous endpoints share a consistent registration flow.
"""
from dataclasses import dataclass
from typing import Callable, Iterable, Mapping
from aiohttp import web
@dataclass(frozen=True)
class RouteDefinition:
"""Declarative definition for a HTTP route."""
method: str
path: str
handler_name: str
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
)
class MiscRouteRegistrar:
"""Bind miscellaneous route definitions to an aiohttp router."""
_METHOD_MAP = {
"GET": "add_get",
"POST": "add_post",
"PUT": "add_put",
"DELETE": "add_delete",
}
def __init__(self, app: web.Application) -> None:
self._app = app
def register_routes(
self,
handler_lookup: Mapping[str, Callable[[web.Request], object]],
*,
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
) -> None:
for definition in definitions:
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
def _bind(self, method: str, path: str, handler: Callable) -> None:
add_method_name = self._METHOD_MAP[method.upper()]
add_method = getattr(self._app.router, add_method_name)
add_method(path, handler)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
"""Route controller for preview asset delivery."""
from __future__ import annotations
from aiohttp import web
from .handlers.preview_handlers import PreviewHandler
class PreviewRoutes:
"""Register routes that expose preview assets."""
def __init__(self, *, handler: PreviewHandler | None = None) -> None:
self._handler = handler or PreviewHandler()
@classmethod
def setup_routes(cls, app: web.Application) -> None:
controller = cls()
controller.register(app)
def register(self, app: web.Application) -> None:
app.router.add_get('/api/lm/previews', self._handler.serve_preview)
__all__ = ["PreviewRoutes"]

View File

@@ -5,12 +5,18 @@ import git
import zipfile
import shutil
import tempfile
from aiohttp import web
import asyncio
from aiohttp import web, ClientError
from typing import Dict, List
from ..utils.settings_paths import ensure_settings_file
from ..services.downloader import get_downloader
logger = logging.getLogger(__name__)
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
class UpdateRoutes:
"""Routes for handling plugin update checks"""
@@ -63,6 +69,12 @@ class UpdateRoutes:
'nightly': nightly
})
except NETWORK_EXCEPTIONS as e:
logger.warning("Network unavailable during update check: %s", e)
return web.json_response({
'success': False,
'error': 'Network unavailable for update check'
})
except Exception as e:
logger.error(f"Failed to check for updates: {e}", exc_info=True)
return web.json_response({
@@ -111,7 +123,7 @@ class UpdateRoutes:
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
settings_path = os.path.join(plugin_root, 'settings.json')
settings_path = ensure_settings_file(logger)
settings_backup = None
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
@@ -283,6 +295,9 @@ class UpdateRoutes:
return version, changelog
except NETWORK_EXCEPTIONS as e:
logger.warning("Unable to reach GitHub for nightly version: %s", e)
return "main", []
except Exception as e:
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
return "main", []
@@ -448,6 +463,9 @@ class UpdateRoutes:
return version, changelog
except NETWORK_EXCEPTIONS as e:
logger.warning("Unable to reach GitHub for release info: %s", e)
return "v0.0.0", []
except Exception as e:
logger.error(f"Error fetching remote version: {e}", exc_info=True)
return "v0.0.0", []

View File

@@ -4,6 +4,7 @@ import logging
import os
from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager
from .model_query import FilterCriteria, ModelCacheRepository, ModelFilterSet, SearchStrategy, SettingsProvider
from .settings_manager import settings as default_settings
@@ -313,24 +314,24 @@ class BaseModelService(ABC):
return {'civitai_url': None, 'model_id': None, 'version_id': None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
"""Get filtered CivitAI metadata for a model by file path"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model.get('file_path') == file_path:
return self.filter_civitai_data(model.get("civitai", {}))
return None
"""Load full metadata for a single model.
Listing/search endpoints return lightweight cache entries; this method performs
a lazy read of the on-disk metadata snapshot when callers need full detail.
"""
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
if should_skip or metadata is None:
return None
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]:
"""Get model description by file path"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model.get('file_path') == file_path:
return model.get('modelDescription', '')
return None
"""Return the stored modelDescription field for a model."""
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
if should_skip or metadata is None:
return None
return metadata.modelDescription or ''
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""

View File

@@ -32,6 +32,24 @@ class CivitaiClient:
self._initialized = True
self.base_url = "https://civitai.com/api/v1"
@staticmethod
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
"""Remove Comfy-specific metadata from model version images."""
if not isinstance(model_version, dict):
return
images = model_version.get("images")
if not isinstance(images, list):
return
for image in images:
if not isinstance(image, dict):
continue
meta = image.get("meta")
if isinstance(meta, dict) and "comfy" in meta:
meta.pop("comfy", None)
async def download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
"""Download file with resumable downloads and retry mechanism
@@ -81,10 +99,11 @@ class CivitaiClient:
# Enrich version_info with model data
result['model']['description'] = data.get("description")
result['model']['tags'] = data.get("tags", [])
# Add creator from model data
result['creator'] = data.get("creator")
self._remove_comfy_metadata(result)
return result, None
# Handle specific error cases
@@ -177,7 +196,8 @@ class CivitaiClient:
version['model']['description'] = model_data.get("description")
version['model']['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
self._remove_comfy_metadata(version)
return version
# Case 2: model_id is provided (with or without version_id)
@@ -260,6 +280,7 @@ class CivitaiClient:
# Add creator from model data
version['creator'] = data.get("creator")
self._remove_comfy_metadata(version)
return version
# Case 3: Neither model_id nor version_id provided
@@ -295,6 +316,7 @@ class CivitaiClient:
if success:
logger.debug(f"Successfully fetched model version info for: {version_id}")
self._remove_comfy_metadata(result)
return result, None
# Handle specific error cases

View File

@@ -3,7 +3,7 @@ import os
import asyncio
from collections import OrderedDict
import uuid
from typing import Dict
from typing import Dict, List
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES, CIVITAI_MODEL_TAGS
from ..utils.exif_utils import ExifUtils
@@ -294,7 +294,18 @@ class DownloadManager:
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if not file_info:
return {'success': False, 'error': 'No primary file found in metadata'}
if not file_info.get('downloadUrl'):
mirrors = file_info.get('mirrors') or []
download_urls = []
if mirrors:
for mirror in mirrors:
if mirror.get('deletedAt') is None and mirror.get('url'):
download_urls.append(mirror['url'])
else:
download_url = file_info.get('downloadUrl')
if download_url:
download_urls.append(download_url)
if not download_urls:
return {'success': False, 'error': 'No download URL found for primary file'}
# 3. Prepare download
@@ -314,7 +325,7 @@ class DownloadManager:
# 6. Start download process
result = await self._execute_download(
download_url=file_info.get('downloadUrl', ''),
download_urls=download_urls,
save_dir=save_dir,
metadata=metadata,
version_info=version_info,
@@ -388,11 +399,14 @@ class DownloadManager:
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
if model_type == 'embedding':
formatted_path = formatted_path.replace(' ', '_')
return formatted_path
async def _execute_download(self, download_url: str, save_dir: str,
metadata, version_info: Dict,
async def _execute_download(self, download_urls: List[str], save_dir: str,
metadata, version_info: Dict,
relative_path: str, progress_callback=None,
model_type: str = "lora", download_id: str = None) -> Dict:
"""Execute the actual download process including preview images and model files"""
@@ -503,33 +517,44 @@ class DownloadManager:
# Download model file with progress tracking using downloader
downloader = await get_downloader()
# Determine if the download URL is from Civitai
use_auth = download_url.startswith("https://civitai.com/api/download/")
success, result = await downloader.download_file(
download_url,
save_path, # Use full path instead of separate dir and filename
progress_callback=lambda p: self._handle_download_progress(p, progress_callback),
use_auth=use_auth # Only use authentication for Civitai downloads
)
last_error = None
for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/")
success, result = await downloader.download_file(
download_url,
save_path, # Use full path instead of separate dir and filename
progress_callback=lambda p: self._handle_download_progress(p, progress_callback),
use_auth=use_auth # Only use authentication for Civitai downloads
)
if not success:
if success:
break
last_error = result
if os.path.exists(save_path):
try:
os.remove(save_path)
except Exception as e:
logger.warning(f"Failed to remove incomplete file {save_path}: {e}")
else:
# Clean up files on failure, but preserve .part file for resume
cleanup_files = [metadata_path]
if metadata.preview_url and os.path.exists(metadata.preview_url):
cleanup_files.append(metadata.preview_url)
preview_path_value = getattr(metadata, 'preview_url', None)
if preview_path_value and os.path.exists(preview_path_value):
cleanup_files.append(preview_path_value)
for path in cleanup_files:
if path and os.path.exists(path):
try:
os.remove(path)
except Exception as e:
logger.warning(f"Failed to cleanup file {path}: {e}")
# Log but don't remove .part file to allow resume
if os.path.exists(part_path):
logger.info(f"Preserving partial download for resume: {part_path}")
return {'success': False, 'error': result}
return {'success': False, 'error': last_error or 'Failed to download file'}
# 4. Update file information (size and modified time)
metadata.update_file_info(save_path)

View File

@@ -8,10 +8,11 @@ import os
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Tuple
from .service_registry import ServiceRegistry
from .settings_manager import settings
from ..utils.example_images_paths import iter_library_roots
logger = logging.getLogger(__name__)
@@ -70,9 +71,9 @@ class ExampleImagesCleanupService:
"error_code": "path_not_configured",
}
example_root = Path(example_images_path)
if not example_root.exists():
logger.debug("Cleanup skipped: example images path missing -> %s", example_root)
base_root = Path(example_images_path)
if not base_root.exists():
logger.debug("Cleanup skipped: example images path missing -> %s", base_root)
return {
"success": False,
"error": "Example images path does not exist.",
@@ -91,9 +92,6 @@ class ExampleImagesCleanupService:
"error_code": "scanner_initialization_failed",
}
deleted_bucket = example_root / self._deleted_folder_name
deleted_bucket.mkdir(exist_ok=True)
checked_folders = 0
moved_empty = 0
moved_orphaned = 0
@@ -101,45 +99,96 @@ class ExampleImagesCleanupService:
move_failures = 0
errors: List[str] = []
for entry in os.scandir(example_root):
if not entry.is_dir(follow_symlinks=False):
resolved_base = base_root.resolve()
library_paths: List[Tuple[str, Path]] = []
processed_paths = {resolved_base}
for library_name, library_path in iter_library_roots():
if not library_path:
continue
if entry.name == self._deleted_folder_name:
continue
checked_folders += 1
folder_path = Path(entry.path)
library_root = Path(library_path)
try:
if self._is_folder_empty(folder_path):
if await self._remove_empty_folder(folder_path):
moved_empty += 1
else:
move_failures += 1
continue
if not self._is_hash_folder(entry.name):
skipped_non_hash += 1
continue
hash_exists = (
lora_scanner.has_hash(entry.name)
or checkpoint_scanner.has_hash(entry.name)
or embedding_scanner.has_hash(entry.name)
resolved = library_root.resolve()
except FileNotFoundError:
continue
if resolved in processed_paths:
continue
if not library_root.exists():
logger.debug(
"Skipping cleanup for library '%s': folder missing (%s)",
library_name,
library_root,
)
continue
processed_paths.add(resolved)
library_paths.append((library_name, library_root))
if not hash_exists:
if await self._move_folder(folder_path, deleted_bucket):
moved_orphaned += 1
else:
move_failures += 1
deleted_roots: List[Path] = []
except Exception as exc: # pragma: no cover - filesystem guard
move_failures += 1
error_message = f"{entry.name}: {exc}"
errors.append(error_message)
logger.error("Error processing example images folder %s: %s", folder_path, exc, exc_info=True)
# Build list of (label, root) pairs including the base root for legacy layouts
cleanup_targets: List[Tuple[str, Path]] = [("__base__", base_root)] + library_paths
library_root_set = {root.resolve() for _, root in library_paths}
for label, root_path in cleanup_targets:
deleted_bucket = root_path / self._deleted_folder_name
deleted_bucket.mkdir(exist_ok=True)
deleted_roots.append(deleted_bucket)
for entry in os.scandir(root_path):
if not entry.is_dir(follow_symlinks=False):
continue
if entry.name == self._deleted_folder_name:
continue
entry_path = Path(entry.path)
if label == "__base__":
try:
resolved_entry = entry_path.resolve()
except FileNotFoundError:
continue
if resolved_entry in library_root_set:
# Skip library-specific folders tracked separately
continue
checked_folders += 1
try:
if self._is_folder_empty(entry_path):
if await self._remove_empty_folder(entry_path):
moved_empty += 1
else:
move_failures += 1
continue
if not self._is_hash_folder(entry.name):
skipped_non_hash += 1
continue
hash_exists = (
lora_scanner.has_hash(entry.name)
or checkpoint_scanner.has_hash(entry.name)
or embedding_scanner.has_hash(entry.name)
)
if not hash_exists:
if await self._move_folder(entry_path, deleted_bucket):
moved_orphaned += 1
else:
move_failures += 1
except Exception as exc: # pragma: no cover - filesystem guard
move_failures += 1
error_message = f"{entry.name}: {exc}"
errors.append(error_message)
logger.error(
"Error processing example images folder %s: %s",
entry_path,
exc,
exc_info=True,
)
partial_success = move_failures > 0 and (moved_empty > 0 or moved_orphaned > 0)
success = move_failures == 0 and not errors
@@ -152,11 +201,12 @@ class ExampleImagesCleanupService:
skipped_non_hash=skipped_non_hash,
move_failures=move_failures,
errors=errors,
deleted_root=str(deleted_bucket),
deleted_root=str(deleted_roots[0]) if deleted_roots else None,
partial_success=partial_success,
)
summary = result.to_dict()
summary["deleted_roots"] = [str(path) for path in deleted_roots]
if success:
logger.info(
"Example images cleanup complete: checked=%s, moved_empty=%s, moved_orphaned=%s",

View File

@@ -37,7 +37,7 @@ async def initialize_metadata_providers():
sqlite_provider = SQLiteModelMetadataProvider(db_path)
provider_manager.register_provider('sqlite', sqlite_provider)
providers.append(('sqlite', sqlite_provider))
logger.info(f"SQLite metadata provider registered with database: {db_path}")
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
else:
logger.warning("Metadata archive database is enabled but database file not found")
except Exception as e:
@@ -72,7 +72,7 @@ async def initialize_metadata_providers():
if ordered_providers:
fallback_provider = FallbackMetadataProvider(ordered_providers)
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
logger.info(f"Fallback metadata provider registered with {len(ordered_providers)} providers, Civitai API first")
logger.debug(f"Fallback metadata provider registered with {len(ordered_providers)} providers, Civitai API first")
elif len(providers) == 1:
# Only one provider available, set it as default
provider_name, provider = providers[0]

View File

@@ -166,10 +166,11 @@ class MetadataSyncService:
try:
if model_data.get("civitai_deleted") is True:
if not enable_archive or model_data.get("db_checked") is True:
return (
False,
"CivitAI model is deleted and metadata archive DB is not enabled",
)
if not enable_archive:
error_msg = "CivitAI model is deleted and metadata archive DB is not enabled"
else:
error_msg = "CivitAI model is deleted and not found in metadata archive DB"
return (False, error_msg)
metadata_provider = await self._get_provider("sqlite")
else:
metadata_provider = await self._get_default_provider()

View File

@@ -389,6 +389,45 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
# Add any additional fields from version data
result.update(version_data)
# Attach files associated with this version from model_files table
files_query = """
SELECT data
FROM model_files
WHERE version_id = ? AND type = 'Model'
ORDER BY id ASC
"""
cursor = await db.execute(files_query, (version_id,))
file_rows = await cursor.fetchall()
files = []
for file_row in file_rows:
try:
file_data = json.loads(file_row['data'])
except json.JSONDecodeError:
logger.warning(
"Skipping model_files entry with invalid JSON for version_id %s", version_id
)
continue
# Remove 'modelId' and 'modelVersionId' fields if present
file_data.pop('modelId', None)
file_data.pop('modelVersionId', None)
files.append(file_data)
if 'files' in result:
existing_files = result['files']
if isinstance(existing_files, list):
existing_files.extend(files)
result['files'] = existing_files
else:
merged_files = files.copy()
if existing_files:
merged_files.insert(0, existing_files)
result['files'] = merged_files
elif files:
result['files'] = files
else:
result['files'] = []
return result
except json.JSONDecodeError:
return None

View File

@@ -4,7 +4,8 @@ import logging
import asyncio
import time
import shutil
from typing import List, Dict, Optional, Type, Set
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set, Type, Union
from ..utils.models import BaseModelMetadata
from ..config import config
@@ -16,9 +17,20 @@ from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache
logger = logging.getLogger(__name__)
@dataclass
class CacheBuildResult:
"""Represents the outcome of scanning model files for cache building."""
raw_data: List[Dict]
hash_index: ModelHashIndex
tags_count: Dict[str, int]
excluded_models: List[str]
class ModelScanner:
"""Base service for scanning and managing model files"""
@@ -68,16 +80,123 @@ class ModelScanner:
self._tags_count = {} # Dictionary to store tag counts
self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models
self._persistent_cache = get_persistent_cache()
self._initialized = True
# Register this service
asyncio.create_task(self._register_service())
def on_library_changed(self) -> None:
"""Reset caches when the active library changes."""
self._persistent_cache = get_persistent_cache()
self._cache = None
self._hash_index = ModelHashIndex()
self._tags_count = {}
self._excluded_models = []
self._is_initializing = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and not loop.is_closed():
loop.create_task(self.initialize_in_background())
async def _register_service(self):
"""Register this instance with the ServiceRegistry"""
service_name = f"{self.model_type}_scanner"
await ServiceRegistry.register_service(service_name, self)
def _slim_civitai_payload(self, civitai: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
"""Return a lightweight civitai payload containing only frequently used keys."""
if not isinstance(civitai, Mapping) or not civitai:
return None
slim: Dict[str, Any] = {}
for key in ('id', 'modelId', 'name'):
value = civitai.get(key)
if value not in (None, '', []):
slim[key] = value
trained_words = civitai.get('trainedWords')
if trained_words:
slim['trainedWords'] = list(trained_words) if isinstance(trained_words, list) else trained_words
return slim or None
def _build_cache_entry(
self,
source: Union[BaseModelMetadata, Mapping[str, Any]],
*,
folder: Optional[str] = None,
file_path_override: Optional[str] = None
) -> Dict[str, Any]:
"""Project metadata into the lightweight cache representation."""
is_mapping = isinstance(source, Mapping)
def get_value(key: str, default: Any = None) -> Any:
if is_mapping:
return source.get(key, default)
return getattr(source, key, default)
file_path = file_path_override or get_value('file_path', '') or ''
normalized_path = file_path.replace('\\', '/')
folder_value = folder if folder is not None else get_value('folder', '') or ''
normalized_folder = folder_value.replace('\\', '/')
tags_value = get_value('tags') or []
if isinstance(tags_value, list):
tags_list = list(tags_value)
elif isinstance(tags_value, (set, tuple)):
tags_list = list(tags_value)
else:
tags_list = []
preview_url = get_value('preview_url', '') or ''
if isinstance(preview_url, str):
preview_url = preview_url.replace('\\', '/')
else:
preview_url = ''
civitai_slim = self._slim_civitai_payload(get_value('civitai'))
usage_tips = get_value('usage_tips', '') or ''
if not isinstance(usage_tips, str):
usage_tips = str(usage_tips)
notes = get_value('notes', '') or ''
if not isinstance(notes, str):
notes = str(notes)
entry: Dict[str, Any] = {
'file_path': normalized_path,
'file_name': get_value('file_name', '') or '',
'model_name': get_value('model_name', '') or '',
'folder': normalized_folder,
'size': int(get_value('size', 0) or 0),
'modified': float(get_value('modified', 0.0) or 0.0),
'sha256': (get_value('sha256', '') or '').lower(),
'base_model': get_value('base_model', '') or '',
'preview_url': preview_url,
'preview_nsfw_level': int(get_value('preview_nsfw_level', 0) or 0),
'from_civitai': bool(get_value('from_civitai', True)),
'favorite': bool(get_value('favorite', False)),
'notes': notes,
'usage_tips': usage_tips,
'exclude': bool(get_value('exclude', False)),
'db_checked': bool(get_value('db_checked', False)),
'last_checked_at': float(get_value('last_checked_at', 0.0) or 0.0),
'tags': tags_list,
'civitai': civitai_slim,
'civitai_deleted': bool(get_value('civitai_deleted', False)),
}
model_type = get_value('model_type', None)
if model_type:
entry['model_type'] = model_type
return entry
async def initialize_in_background(self) -> None:
"""Initialize cache in background using thread pool"""
try:
@@ -92,7 +211,12 @@ class ModelScanner:
self._is_initializing = True
# Determine the page type based on model type
page_type = 'loras' if self.model_type == 'lora' else 'checkpoints'
page_type_map = {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings'
}
page_type = page_type_map.get(self.model_type, self.model_type)
# First, try to load from cache
await ws_manager.broadcast_init_progress({
@@ -102,8 +226,25 @@ class ModelScanner:
'scanner_type': self.model_type,
'pageType': page_type
})
# If cache loading failed, proceed with full scan
cache_loaded = await self._load_persisted_cache(page_type)
if cache_loaded:
await asyncio.sleep(0) # Yield control so the UI can process the cache hydration update
await ws_manager.broadcast_init_progress({
'stage': 'finalizing',
'progress': 100,
'status': 'complete',
'details': f"Loaded {len(self._cache.raw_data)} cached {self.model_type} files from disk.",
'scanner_type': self.model_type,
'pageType': page_type
})
logger.info(
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
)
return
# Persistent load failed; fall back to a full scan
await ws_manager.broadcast_init_progress({
'stage': 'scan_folders',
'progress': 0,
@@ -130,13 +271,17 @@ class ModelScanner:
start_time = time.time()
# Use thread pool to execute CPU-intensive operations with progress reporting
await loop.run_in_executor(
scan_result: Optional[CacheBuildResult] = await loop.run_in_executor(
None, # Use default thread pool
self._initialize_cache_sync, # Run synchronous version in thread
total_files, # Pass the total file count for progress reporting
page_type # Pass the page type for progress reporting
)
if scan_result:
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
# Send final progress update
await ws_manager.broadcast_init_progress({
'stage': 'finalizing',
@@ -165,6 +310,105 @@ class ModelScanner:
# Always clear the initializing flag when done
self._is_initializing = False
async def _load_persisted_cache(self, page_type: str) -> bool:
"""Attempt to hydrate the in-memory cache from the SQLite snapshot."""
if not getattr(self, '_persistent_cache', None):
return False
loop = asyncio.get_event_loop()
try:
persisted = await loop.run_in_executor(
None,
self._persistent_cache.load_cache,
self.model_type
)
except FileNotFoundError:
return False
except Exception as exc:
logger.debug("%s Scanner: Could not load persisted cache: %s", self.model_type.capitalize(), exc)
return False
if not persisted or not persisted.raw_data:
return False
hash_index = ModelHashIndex()
for sha_value, path in persisted.hash_rows:
if sha_value and path:
hash_index.add_entry(sha_value.lower(), path)
tags_count: Dict[str, int] = {}
for item in persisted.raw_data:
for tag in item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
scan_result = CacheBuildResult(
raw_data=list(persisted.raw_data),
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
)
await self._apply_scan_result(scan_result)
await ws_manager.broadcast_init_progress({
'stage': 'loading_cache',
'progress': 1,
'details': f"Loaded cached {self.model_type} data from disk",
'scanner_type': self.model_type,
'pageType': page_type
})
return True
async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
if not scan_result or not getattr(self, '_persistent_cache', None):
return
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(
None,
self._persistent_cache.save_cache,
self.model_type,
list(scan_result.raw_data),
hash_snapshot,
list(scan_result.excluded_models)
)
except Exception as exc:
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
def _build_hash_index_snapshot(self, hash_index: Optional[ModelHashIndex]) -> Dict[str, List[str]]:
snapshot: Dict[str, List[str]] = {}
if not hash_index:
return snapshot
for sha_value, path in getattr(hash_index, '_hash_to_path', {}).items():
if not sha_value or not path:
continue
bucket = snapshot.setdefault(sha_value.lower(), [])
if path not in bucket:
bucket.append(path)
for sha_value, paths in getattr(hash_index, '_duplicate_hashes', {}).items():
if not sha_value:
continue
bucket = snapshot.setdefault(sha_value.lower(), [])
for path in paths:
if path and path not in bucket:
bucket.append(path)
return snapshot
async def _persist_current_cache(self) -> None:
if self._cache is None or not getattr(self, '_persistent_cache', None):
return
snapshot = CacheBuildResult(
raw_data=list(self._cache.raw_data),
hash_index=self._hash_index,
tags_count=dict(self._tags_count),
excluded_models=list(self._excluded_models)
)
await self._save_persistent_cache(snapshot)
def _count_model_files(self) -> int:
"""Count all model files with supported extensions in all roots
@@ -204,124 +448,53 @@ class ModelScanner:
return total_files
def _initialize_cache_sync(self, total_files=0, page_type='loras'):
def _initialize_cache_sync(self, total_files: int = 0, page_type: str = 'loras') -> Optional[CacheBuildResult]:
"""Synchronous version of cache initialization for thread pool execution"""
loop = asyncio.new_event_loop()
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Create a synchronous method to bypass the async lock
def sync_initialize_cache():
# Track progress
processed_files = 0
last_progress_time = time.time()
last_progress_percent = 0
# We need a wrapper around scan_all_models to track progress
# This is a local function that will run in our thread's event loop
async def scan_with_progress():
nonlocal processed_files, last_progress_time, last_progress_percent
# For storing raw model data
all_models = []
# Process each model root
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
# Track visited paths to avoid symlink loops
visited_paths = set()
# Recursively process directory
async def scan_dir_with_progress(path):
nonlocal processed_files, last_progress_time, last_progress_percent
try:
real_path = os.path.realpath(path)
if real_path in visited_paths:
return
visited_paths.add(real_path)
with os.scandir(path) as it:
entries = list(it)
for entry in entries:
try:
if entry.is_file(follow_symlinks=True):
ext = os.path.splitext(entry.name)[1].lower()
if ext in self.file_extensions:
file_path = entry.path.replace(os.sep, "/")
result = await self._process_model_file(file_path, root_path)
if result:
all_models.append(result)
# Update progress counter
processed_files += 1
# Update progress periodically (not every file to avoid excessive updates)
current_time = time.time()
if total_files > 0 and (current_time - last_progress_time > 0.5 or processed_files == total_files):
# Adjusted progress calculation
progress_percent = min(99, int(1 + (processed_files / total_files) * 98))
if progress_percent > last_progress_percent:
last_progress_percent = progress_percent
last_progress_time = current_time
# Send progress update through websocket
await ws_manager.broadcast_init_progress({
'stage': 'process_models',
'progress': progress_percent,
'details': f"Processing {self.model_type} files: {processed_files}/{total_files}",
'scanner_type': self.model_type,
'pageType': page_type
})
elif entry.is_dir(follow_symlinks=True):
await scan_dir_with_progress(entry.path)
except Exception as e:
logger.error(f"Error processing entry {entry.path}: {e}")
except Exception as e:
logger.error(f"Error scanning {path}: {e}")
# Process the root path
await scan_dir_with_progress(root_path)
return all_models
# Run the progress-tracking scan function
raw_data = loop.run_until_complete(scan_with_progress())
# Update hash index and tags count
for model_data in raw_data:
if 'sha256' in model_data and 'file_path' in model_data:
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
# Count tags
if 'tags' in model_data and model_data['tags']:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
# if duplicate_filenames:
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
# for filename, paths in duplicate_filenames.items():
# logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache
self._cache.raw_data = raw_data
loop.run_until_complete(self._cache.resort())
return self._cache
# Run our sync initialization that avoids lock conflicts
return sync_initialize_cache()
last_progress_time = time.time()
last_progress_percent = 0
async def progress_callback(processed_files: int, expected_total: int) -> None:
nonlocal last_progress_time, last_progress_percent
if expected_total <= 0:
return
current_time = time.time()
progress_percent = min(99, int(1 + (processed_files / expected_total) * 98))
if progress_percent <= last_progress_percent:
return
if current_time - last_progress_time <= 0.5 and processed_files != expected_total:
return
last_progress_percent = progress_percent
last_progress_time = current_time
await ws_manager.broadcast_init_progress({
'stage': 'process_models',
'progress': progress_percent,
'details': f"Processing {self.model_type} files: {processed_files}/{expected_total}",
'scanner_type': self.model_type,
'pageType': page_type
})
return loop.run_until_complete(
self._gather_model_data(
total_files=total_files,
progress_callback=progress_callback
)
)
except Exception as e:
logger.error(f"Error in thread-based {self.model_type} cache initialization: {e}")
return None
finally:
# Clean up the event loop
asyncio.set_event_loop(None)
loop.close()
async def get_cached_data(self, force_refresh: bool = False, rebuild_cache: bool = False) -> ModelCache:
@@ -353,45 +526,16 @@ class ModelScanner:
self._is_initializing = True # Set flag
try:
start_time = time.time()
# Clear existing hash index
self._hash_index.clear()
# Clear existing tags count
self._tags_count = {}
# Determine the page type based on model type
page_type = 'loras' if self.model_type == 'lora' else 'checkpoints'
# Scan for new data
raw_data = await self.scan_all_models()
# Build hash index and tags count
for model_data in raw_data:
if 'sha256' in model_data and 'file_path' in model_data:
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
# Count tags
if 'tags' in model_data and model_data['tags']:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
# if duplicate_filenames:
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
# for filename, paths in duplicate_filenames.items():
# logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache
self._cache = ModelCache(
raw_data=raw_data,
folders=[]
)
# Resort cache
await self._cache.resort()
scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
logger.info(f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, found {len(raw_data)} models")
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models"
)
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
# Ensure cache is at least an empty structure on error
@@ -476,8 +620,12 @@ class ModelScanner:
try:
# Find the appropriate root path for this file
root_path = None
for potential_root in self.get_model_roots():
if path.startswith(potential_root):
model_roots = self.get_model_roots()
for potential_root in model_roots:
# Normalize both paths for comparison
normalized_path = os.path.normpath(path)
normalized_root = os.path.normpath(potential_root)
if normalized_path.startswith(normalized_root):
root_path = potential_root
break
@@ -535,70 +683,17 @@ class ModelScanner:
# Update folders list
all_folders = set(item.get('folder', '') for item in self._cache.raw_data)
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Resort cache
await self._cache.resort()
await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
finally:
self._is_initializing = False # Unset flag
async def scan_all_models(self) -> List[Dict]:
"""Scan all model directories and return metadata"""
all_models = []
# Create scan tasks for each directory
scan_tasks = []
for model_root in self.get_model_roots():
task = asyncio.create_task(self._scan_directory(model_root))
scan_tasks.append(task)
# Wait for all tasks to complete
for task in scan_tasks:
try:
models = await task
all_models.extend(models)
except Exception as e:
logger.error(f"Error scanning directory: {e}")
return all_models
async def _scan_directory(self, root_path: str) -> List[Dict]:
"""Scan a single directory for model files"""
models = []
original_root = root_path # Save original root path
async def scan_recursive(path: str, visited_paths: set):
"""Recursively scan directory, avoiding circular symlinks"""
try:
real_path = os.path.realpath(path)
if real_path in visited_paths:
logger.debug(f"Skipping already visited path: {path}")
return
visited_paths.add(real_path)
with os.scandir(path) as it:
entries = list(it)
for entry in entries:
try:
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
file_path = entry.path.replace(os.sep, "/")
result = await self._process_model_file(file_path, original_root)
# Only add to models if result is not None (skip corrupted metadata)
if result:
models.append(result)
await asyncio.sleep(0)
elif entry.is_dir(follow_symlinks=True):
await scan_recursive(entry.path, visited_paths)
except Exception as e:
logger.error(f"Error processing entry {entry.path}: {e}")
except Exception as e:
logger.error(f"Error scanning {path}: {e}")
await scan_recursive(root_path, set())
return models
def is_initializing(self) -> bool:
"""Check if the scanner is currently initializing"""
@@ -624,8 +719,18 @@ class ModelScanner:
"""Hook for subclasses: adjust metadata during scanning"""
return metadata
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
async def _process_model_file(
self,
file_path: str,
root_path: str,
*,
hash_index: Optional[ModelHashIndex] = None,
excluded_models: Optional[List[str]] = None
) -> Dict:
"""Process a single model file and return its metadata"""
hash_index = hash_index or self._hash_index
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
if should_skip:
@@ -685,30 +790,134 @@ class ModelScanner:
# Hook: allow subclasses to adjust metadata
metadata = self.adjust_metadata(metadata, file_path, root_path)
model_data = metadata.to_dict()
# Skip excluded models
if model_data.get('exclude', False):
self._excluded_models.append(model_data['file_path'])
return None
# Check for duplicate filename before adding to hash index
filename = os.path.splitext(os.path.basename(file_path))[0]
existing_hash = self._hash_index.get_hash_by_filename(filename)
if existing_hash and existing_hash != model_data.get('sha256', '').lower():
existing_path = self._hash_index.get_path(existing_hash)
if existing_path and existing_path != file_path:
logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path)
model_data['folder'] = folder.replace(os.path.sep, '/')
normalized_folder = folder.replace(os.path.sep, '/')
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
# Skip excluded models
if model_data.get('exclude', False):
excluded_models.append(model_data['file_path'])
return None
# Check for duplicate filename before adding to hash index
# filename = os.path.splitext(os.path.basename(file_path))[0]
# existing_hash = hash_index.get_hash_by_filename(filename)
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
# existing_path = hash_index.get_path(existing_hash)
# if existing_path and existing_path != file_path:
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
return model_data
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
"""Apply scan results to the cache and associated indexes."""
if scan_result is None:
return
self._hash_index = scan_result.hash_index
self._tags_count = dict(scan_result.tags_count)
self._excluded_models = list(scan_result.excluded_models)
if self._cache is None:
self._cache = ModelCache(
raw_data=list(scan_result.raw_data),
folders=[]
)
else:
self._cache.raw_data = list(scan_result.raw_data)
await self._cache.resort()
async def _gather_model_data(
self,
*,
total_files: int = 0,
progress_callback: Optional[Callable[[int, int], Awaitable[None]]] = None
) -> CacheBuildResult:
"""Collect metadata for all model files."""
raw_data: List[Dict] = []
hash_index = ModelHashIndex()
tags_count: Dict[str, int] = {}
excluded_models: List[str] = []
processed_files = 0
async def handle_progress() -> None:
if progress_callback is None:
return
try:
await progress_callback(processed_files, total_files)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None:
nonlocal processed_files
try:
real_path = os.path.realpath(current_path)
if real_path in visited_paths:
return
visited_paths.add(real_path)
with os.scandir(current_path) as iterator:
entries = list(iterator)
for entry in entries:
try:
if entry.is_file(follow_symlinks=True):
ext = os.path.splitext(entry.name)[1].lower()
if ext not in self.file_extensions:
continue
file_path = entry.path.replace(os.sep, "/")
result = await self._process_model_file(
file_path,
root_path,
hash_index=hash_index,
excluded_models=excluded_models
)
processed_files += 1
if result:
raw_data.append(result)
sha_value = result.get('sha256')
model_path = result.get('file_path')
if sha_value and model_path:
hash_index.add_entry(sha_value.lower(), model_path)
for tag in result.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
await handle_progress()
await asyncio.sleep(0)
elif entry.is_dir(follow_symlinks=True):
await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error:
logger.error(f"Error processing entry {entry.path}: {entry_error}")
except Exception as scan_error:
logger.error(f"Error scanning {current_path}: {scan_error}")
for model_root in self.get_model_roots():
if not os.path.exists(model_root):
continue
await scan_recursive(model_root, model_root, set())
return CacheBuildResult(
raw_data=raw_data,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=excluded_models
)
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
"""Add a model to the cache
Args:
metadata_dict: The model metadata dictionary
folder: The relative folder path for the model
@@ -736,6 +945,7 @@ class ModelScanner:
# Update the hash index
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
await self._persist_current_cache()
return True
except Exception as e:
logger.error(f"Error adding model to cache: {e}")
@@ -864,7 +1074,7 @@ class ModelScanner:
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
"""Update cache after a model has been moved or modified"""
cache = await self.get_cached_data()
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
if existing_item and 'tags' in existing_item:
for tag in existing_item.get('tags', []):
@@ -876,35 +1086,42 @@ class ModelScanner:
self._hash_index.remove_by_path(original_path)
cache.raw_data = [
item for item in cache.raw_data
item for item in cache.raw_data
if item['file_path'] != original_path
]
cache_modified = bool(existing_item) or bool(metadata)
if metadata:
if original_path == new_path:
existing_folder = next((item['folder'] for item in cache.raw_data
if item['file_path'] == original_path), None)
if existing_folder:
metadata['folder'] = existing_folder
else:
metadata['folder'] = self._calculate_folder(new_path)
normalized_new_path = new_path.replace(os.sep, '/')
if original_path == new_path and existing_item:
folder_value = existing_item.get('folder', self._calculate_folder(new_path))
else:
metadata['folder'] = self._calculate_folder(new_path)
cache.raw_data.append(metadata)
if 'sha256' in metadata:
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
folder_value = self._calculate_folder(new_path)
cache_entry = self._build_cache_entry(
metadata,
folder=folder_value,
file_path_override=normalized_new_path,
)
cache.raw_data.append(cache_entry)
sha_value = cache_entry.get('sha256')
if sha_value:
self._hash_index.add_entry(sha_value.lower(), normalized_new_path)
all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
if 'tags' in metadata:
for tag in metadata.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
await cache.resort()
if cache_modified:
await self._persist_current_cache()
return True
def has_hash(self, sha256: str) -> bool:
@@ -1006,7 +1223,10 @@ class ModelScanner:
if self._cache is None:
return False
return await self._cache.update_preview_url(file_path, preview_url, preview_nsfw_level)
updated = await self._cache.update_preview_url(file_path, preview_url, preview_nsfw_level)
if updated:
await self._persist_current_cache()
return updated
async def bulk_delete_models(self, file_paths: List[str]) -> Dict:
"""Delete multiple models and update cache in a batch operation
@@ -1135,7 +1355,9 @@ class ModelScanner:
# Resort cache
await self._cache.resort()
await self._persist_current_cache()
return True
except Exception as e:

View File

@@ -0,0 +1,357 @@
import json
import logging
import os
import re
import sqlite3
import threading
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
@dataclass
class PersistedCacheData:
"""Lightweight structure returned by the persistent cache."""
raw_data: List[Dict]
hash_rows: List[Tuple[str, str]]
excluded_models: List[str]
class PersistentModelCache:
"""Persist core model metadata and hash index data in SQLite."""
_DEFAULT_FILENAME = "model_cache.sqlite"
_instances: Dict[str, "PersistentModelCache"] = {}
_instance_lock = threading.Lock()
def __init__(self, library_name: str = "default", db_path: Optional[str] = None) -> None:
self._library_name = library_name or "default"
self._db_path = db_path or self._resolve_default_path(self._library_name)
self._db_lock = threading.Lock()
self._schema_initialized = False
try:
directory = os.path.dirname(self._db_path)
if directory:
os.makedirs(directory, exist_ok=True)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning("Could not create cache directory %s: %s", directory, exc)
if self.is_enabled():
self._initialize_schema()
@classmethod
def get_default(cls, library_name: Optional[str] = None) -> "PersistentModelCache":
name = (library_name or "default")
with cls._instance_lock:
if name not in cls._instances:
cls._instances[name] = cls(name)
return cls._instances[name]
def is_enabled(self) -> bool:
return os.environ.get("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "0") != "1"
def load_cache(self, model_type: str) -> Optional[PersistedCacheData]:
if not self.is_enabled():
return None
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return None
try:
with self._db_lock:
conn = self._connect(readonly=True)
try:
rows = conn.execute(
"SELECT file_path, file_name, model_name, folder, size, modified, sha256, base_model,"
" preview_url, preview_nsfw_level, from_civitai, favorite, notes, usage_tips,"
" civitai_id, civitai_model_id, civitai_name, trained_words, exclude, db_checked,"
" last_checked_at"
" FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
if not rows:
return None
tags = self._load_tags(conn, model_type)
hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
excluded = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to load persisted cache for %s: %s", model_type, exc)
return None
raw_data: List[Dict] = []
for row in rows:
file_path: str = row["file_path"]
trained_words = []
if row["trained_words"]:
try:
trained_words = json.loads(row["trained_words"])
except json.JSONDecodeError:
trained_words = []
civitai: Optional[Dict] = None
if any(row[col] is not None for col in ("civitai_id", "civitai_model_id", "civitai_name")):
civitai = {}
if row["civitai_id"] is not None:
civitai["id"] = row["civitai_id"]
if row["civitai_model_id"] is not None:
civitai["modelId"] = row["civitai_model_id"]
if row["civitai_name"]:
civitai["name"] = row["civitai_name"]
if trained_words:
civitai["trainedWords"] = trained_words
item = {
"file_path": file_path,
"file_name": row["file_name"],
"model_name": row["model_name"],
"folder": row["folder"] or "",
"size": row["size"] or 0,
"modified": row["modified"] or 0.0,
"sha256": row["sha256"] or "",
"base_model": row["base_model"] or "",
"preview_url": row["preview_url"] or "",
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
"from_civitai": bool(row["from_civitai"]),
"favorite": bool(row["favorite"]),
"notes": row["notes"] or "",
"usage_tips": row["usage_tips"] or "",
"exclude": bool(row["exclude"]),
"db_checked": bool(row["db_checked"]),
"last_checked_at": row["last_checked_at"] or 0.0,
"tags": tags.get(file_path, []),
"civitai": civitai,
}
raw_data.append(item)
hash_pairs = [(entry["sha256"].lower(), entry["file_path"]) for entry in hash_rows if entry["sha256"]]
if not hash_pairs:
# Fall back to hashes stored on the model rows
for item in raw_data:
sha_value = item.get("sha256")
if sha_value:
hash_pairs.append((sha_value.lower(), item["file_path"]))
excluded_paths = [row["file_path"] for row in excluded]
return PersistedCacheData(raw_data=raw_data, hash_rows=hash_pairs, excluded_models=excluded_paths)
def save_cache(self, model_type: str, raw_data: Sequence[Dict], hash_index: Dict[str, List[str]], excluded_models: Sequence[str]) -> None:
if not self.is_enabled():
return
if not self._schema_initialized:
self._initialize_schema()
if not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("DELETE FROM models WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM model_tags WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM hash_index WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM excluded_models WHERE model_type = ?", (model_type,))
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
conn.executemany(self._insert_model_sql(), model_rows)
tag_rows = []
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
for tag in item.get("tags") or []:
tag_rows.append((model_type, file_path, tag))
if tag_rows:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_rows,
)
hash_rows: List[Tuple[str, str, str]] = []
for sha_value, paths in hash_index.items():
for path in paths:
if not sha_value or not path:
continue
hash_rows.append((model_type, sha_value.lower(), path))
if hash_rows:
conn.executemany(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_rows,
)
excluded_rows = [(model_type, path) for path in excluded_models]
if excluded_rows:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_rows,
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
# Internal helpers -------------------------------------------------
def _resolve_default_path(self, library_name: str) -> str:
override = os.environ.get("LORA_MANAGER_CACHE_DB")
if override:
return override
try:
settings_dir = get_settings_dir(create=True)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning("Falling back to project directory for cache: %s", exc)
settings_dir = os.path.dirname(os.path.dirname(self._db_path)) if hasattr(self, "_db_path") else os.getcwd()
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", library_name or "default")
if safe_name.lower() in ("default", ""):
legacy_path = os.path.join(settings_dir, self._DEFAULT_FILENAME)
if os.path.exists(legacy_path):
return legacy_path
return os.path.join(settings_dir, "model_cache", f"{safe_name}.sqlite")
def _initialize_schema(self) -> None:
with self._db_lock:
if self._schema_initialized:
return
try:
with self._connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS models (
model_type TEXT NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT,
model_name TEXT,
folder TEXT,
size INTEGER,
modified REAL,
sha256 TEXT,
base_model TEXT,
preview_url TEXT,
preview_nsfw_level INTEGER,
from_civitai INTEGER,
favorite INTEGER,
notes TEXT,
usage_tips TEXT,
civitai_id INTEGER,
civitai_model_id INTEGER,
civitai_name TEXT,
trained_words TEXT,
exclude INTEGER,
db_checked INTEGER,
last_checked_at REAL,
PRIMARY KEY (model_type, file_path)
);
CREATE TABLE IF NOT EXISTS model_tags (
model_type TEXT NOT NULL,
file_path TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (model_type, file_path, tag)
);
CREATE TABLE IF NOT EXISTS hash_index (
model_type TEXT NOT NULL,
sha256 TEXT NOT NULL,
file_path TEXT NOT NULL,
PRIMARY KEY (model_type, sha256, file_path)
);
CREATE TABLE IF NOT EXISTS excluded_models (
model_type TEXT NOT NULL,
file_path TEXT NOT NULL,
PRIMARY KEY (model_type, file_path)
);
"""
)
conn.commit()
self._schema_initialized = True
except Exception as exc: # pragma: no cover - defensive guard
logger.warning("Failed to initialize persistent cache schema: %s", exc)
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
conn.row_factory = sqlite3.Row
return conn
def _prepare_model_row(self, model_type: str, item: Dict) -> Tuple:
civitai = item.get("civitai") or {}
trained_words = civitai.get("trainedWords")
if isinstance(trained_words, str):
trained_words_json = trained_words
elif trained_words is None:
trained_words_json = None
else:
trained_words_json = json.dumps(trained_words)
return (
model_type,
item.get("file_path"),
item.get("file_name"),
item.get("model_name"),
item.get("folder"),
int(item.get("size") or 0),
float(item.get("modified") or 0.0),
(item.get("sha256") or "").lower() or None,
item.get("base_model"),
item.get("preview_url"),
int(item.get("preview_nsfw_level") or 0),
1 if item.get("from_civitai", True) else 0,
1 if item.get("favorite") else 0,
item.get("notes"),
item.get("usage_tips"),
civitai.get("id"),
civitai.get("modelId"),
civitai.get("name"),
trained_words_json,
1 if item.get("exclude") else 0,
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
)
def _insert_model_sql(self) -> str:
return (
"INSERT INTO models (model_type, file_path, file_name, model_name, folder, size, modified, sha256,"
" base_model, preview_url, preview_nsfw_level, from_civitai, favorite, notes, usage_tips,"
" civitai_id, civitai_model_id, civitai_name, trained_words, exclude, db_checked, last_checked_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
tag_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
result: Dict[str, List[str]] = {}
for row in tag_rows:
result.setdefault(row["file_path"], []).append(row["tag"])
return result
def get_persistent_cache() -> PersistentModelCache:
from .settings_manager import settings as settings_service # Local import to avoid cycles
library_name = settings_service.get_active_library_name()
return PersistentModelCache.get_default(library_name)

View File

@@ -52,6 +52,31 @@ class RecipeScanner:
if lora_scanner:
self._lora_scanner = lora_scanner
self._initialized = True
def on_library_changed(self) -> None:
"""Reset cached state when the active library changes."""
# Cancel any in-flight initialization or resorting work so the next
# access rebuilds the cache for the new library.
if self._initialization_task and not self._initialization_task.done():
self._initialization_task.cancel()
for task in list(self._resort_tasks):
if not task.done():
task.cancel()
self._resort_tasks.clear()
self._cache = None
self._initialization_task = None
self._is_initializing = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and not loop.is_closed():
loop.create_task(self.initialize_in_background())
async def _get_civitai_client(self):
"""Lazily initialize CivitaiClient from registry"""
@@ -424,27 +449,29 @@ class RecipeScanner:
# If has modelVersionId but no hash, look in lora cache first, then fetch from Civitai
if 'modelVersionId' in lora and not lora.get('hash'):
model_version_id = lora['modelVersionId']
# Check if model_version_id is an integer and > 0
if isinstance(model_version_id, int) and model_version_id > 0:
# Try to find in lora cache first
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
if hash_from_cache:
lora['hash'] = hash_from_cache
metadata_updated = True
else:
# If not in cache, fetch from Civitai
result = await self._get_hash_from_civitai(model_version_id)
if isinstance(result, tuple):
hash_from_civitai, is_deleted = result
if hash_from_civitai:
lora['hash'] = hash_from_civitai
metadata_updated = True
elif is_deleted:
# Mark the lora as deleted if it was not found on Civitai
lora['isDeleted'] = True
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
metadata_updated = True
# Try to find in lora cache first
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
if hash_from_cache:
lora['hash'] = hash_from_cache
metadata_updated = True
else:
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
# If not in cache, fetch from Civitai
result = await self._get_hash_from_civitai(model_version_id)
if isinstance(result, tuple):
hash_from_civitai, is_deleted = result
if hash_from_civitai:
lora['hash'] = hash_from_civitai
metadata_updated = True
elif is_deleted:
# Mark the lora as deleted if it was not found on Civitai
lora['isDeleted'] = True
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
metadata_updated = True
else:
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
# If has hash but no file_name, look up in lora library
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
@@ -740,20 +767,17 @@ class RecipeScanner:
"""Format file path as URL for serving in web UI"""
if not file_path:
return '/loras_static/images/no-preview.png'
try:
# Format file path as a URL that will work with static file serving
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/')
if file_path.replace(os.sep, '/').startswith(recipes_dir):
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/')
return f"/loras_static/root1/preview/{relative_path}"
# If not in recipes dir, try to create a valid URL from the file name
file_name = os.path.basename(file_path)
return f"/loras_static/root1/preview/recipes/{file_name}"
normalized_path = os.path.normpath(file_path)
static_url = config.get_preview_static_url(normalized_path)
if static_url:
return static_url
except Exception as e:
logger.error(f"Error formatting file URL: {e}")
return '/loras_static/images/no-preview.png'
return '/loras_static/images/no-preview.png'
def _format_timestamp(self, timestamp: float) -> str:
"""Format timestamp for display"""

View File

@@ -279,10 +279,17 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
image_filename = f"{recipe_id}.png"
optimized_image, extension = self._exif_utils.optimize_image(
image_data=image_bytes,
target_width=self._card_preview_width,
format="webp",
quality=85,
preserve_metadata=True,
)
image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename)
with open(image_path, "wb") as file_obj:
file_obj.write(image_bytes)
file_obj.write(optimized_image)
lora_stack = metadata.get("loras", "")
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", lora_stack)
@@ -298,9 +305,9 @@ class RecipePersistenceService:
"file_name": name,
"strength": float(strength),
"hash": (lora_info.get("sha256") or "").lower() if lora_info else "",
"modelVersionId": lora_info.get("civitai", {}).get("id") if lora_info else 0,
"modelName": lora_info.get("civitai", {}).get("model", {}).get("name") if lora_info else "",
"modelVersionName": lora_info.get("civitai", {}).get("name") if lora_info else "",
"modelVersionId": (lora_info.get("civitai") or {}).get("id", 0) if lora_info else 0,
"modelName": ((lora_info.get("civitai") or {}).get("model") or {}).get("name", name) if lora_info else "",
"modelVersionName": (lora_info.get("civitai") or {}).get("name", "") if lora_info else "",
"isDeleted": False,
"exclude": False,
}

View File

@@ -1,7 +1,11 @@
import os
import copy
import json
import os
import logging
from typing import Any, Dict
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional
from ..utils.settings_paths import ensure_settings_file
logger = logging.getLogger(__name__)
@@ -36,10 +40,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
class SettingsManager:
def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings_file = ensure_settings_file(logger)
self.settings = self._load_settings()
self._migrate_setting_keys()
self._ensure_default_settings()
self._migrate_to_library_registry()
self._migrate_download_path_template()
self._auto_set_default_roots()
self._check_environment_variables()
@@ -67,6 +72,223 @@ class SettingsManager:
if updated:
self._save_settings()
def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure."""
libraries = self.settings.get("libraries")
active_name = self.settings.get("active_library")
if not isinstance(libraries, dict) or not libraries:
library_name = active_name or "default"
library_payload = self._build_library_payload(
folder_paths=self.settings.get("folder_paths", {}),
default_lora_root=self.settings.get("default_lora_root", ""),
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""),
)
libraries = {library_name: library_payload}
self.settings["libraries"] = libraries
self.settings["active_library"] = library_name
self._sync_active_library_to_root(save=False)
self._save_settings()
return
sanitized_libraries: Dict[str, Dict[str, Any]] = {}
changed = False
for name, data in libraries.items():
if not isinstance(data, dict):
data = {}
changed = True
payload = self._build_library_payload(
folder_paths=data.get("folder_paths"),
default_lora_root=data.get("default_lora_root"),
default_checkpoint_root=data.get("default_checkpoint_root"),
default_embedding_root=data.get("default_embedding_root"),
metadata=data.get("metadata"),
base=data,
)
sanitized_libraries[name] = payload
if payload is not data:
changed = True
if changed:
self.settings["libraries"] = sanitized_libraries
if not active_name or active_name not in sanitized_libraries:
if sanitized_libraries:
self.settings["active_library"] = next(iter(sanitized_libraries.keys()))
else:
self.settings["active_library"] = "default"
self._sync_active_library_to_root(save=changed)
def _sync_active_library_to_root(self, *, save: bool = False) -> None:
"""Update top-level folder path settings to mirror the active library."""
libraries = self.settings.get("libraries", {})
active_name = self.settings.get("active_library")
if not libraries:
return
if active_name not in libraries:
active_name = next(iter(libraries.keys()))
self.settings["active_library"] = active_name
active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = folder_paths
self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
if save:
self._save_settings()
def _current_timestamp(self) -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _build_library_payload(
self,
*,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = dict(base or {})
timestamp = self._current_timestamp()
if folder_paths is not None:
payload["folder_paths"] = self._normalize_folder_paths(folder_paths)
else:
payload.setdefault("folder_paths", {})
if default_lora_root is not None:
payload["default_lora_root"] = default_lora_root
else:
payload.setdefault("default_lora_root", "")
if default_checkpoint_root is not None:
payload["default_checkpoint_root"] = default_checkpoint_root
else:
payload.setdefault("default_checkpoint_root", "")
if default_embedding_root is not None:
payload["default_embedding_root"] = default_embedding_root
else:
payload.setdefault("default_embedding_root", "")
if metadata:
merged_meta = dict(payload.get("metadata", {}))
merged_meta.update(metadata)
payload["metadata"] = merged_meta
payload.setdefault("created_at", timestamp)
payload["updated_at"] = timestamp
return payload
def _normalize_folder_paths(
self, folder_paths: Mapping[str, Iterable[str]]
) -> Dict[str, List[str]]:
normalized: Dict[str, List[str]] = {}
for key, values in folder_paths.items():
if not isinstance(values, Iterable):
continue
cleaned: List[str] = []
seen = set()
for value in values:
if not isinstance(value, str):
continue
stripped = value.strip()
if not stripped:
continue
if stripped not in seen:
cleaned.append(stripped)
seen.add(stripped)
normalized[key] = cleaned
return normalized
def _validate_folder_paths(
self,
library_name: str,
folder_paths: Mapping[str, Iterable[str]],
) -> None:
"""Ensure folder paths do not overlap with other libraries."""
libraries = self.settings.get("libraries", {})
normalized_new: Dict[str, Dict[str, str]] = {}
for key, values in folder_paths.items():
path_map: Dict[str, str] = {}
for value in values:
if not isinstance(value, str):
continue
stripped = value.strip()
if not stripped:
continue
normalized_value = os.path.normcase(os.path.normpath(stripped))
path_map[normalized_value] = stripped
if path_map:
normalized_new[key] = path_map
if not normalized_new:
return
for other_name, other in libraries.items():
if other_name == library_name:
continue
other_paths = other.get("folder_paths", {})
for key, new_paths in normalized_new.items():
existing = {
os.path.normcase(os.path.normpath(path))
for path in other_paths.get(key, [])
if isinstance(path, str) and path
}
overlap = existing.intersection(new_paths.keys())
if overlap:
collisions = ", ".join(sorted(new_paths[value] for value in overlap))
raise ValueError(
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
)
def _update_active_library_entry(
self,
*,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
) -> bool:
libraries = self.settings.get("libraries", {})
active_name = self.settings.get("active_library")
if not active_name or active_name not in libraries:
return False
library = libraries[active_name]
changed = False
if folder_paths is not None:
normalized_paths = self._normalize_folder_paths(folder_paths)
if library.get("folder_paths") != normalized_paths:
library["folder_paths"] = normalized_paths
changed = True
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root:
library["default_lora_root"] = default_lora_root
changed = True
if default_checkpoint_root is not None and library.get("default_checkpoint_root") != default_checkpoint_root:
library["default_checkpoint_root"] = default_checkpoint_root
changed = True
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
library["default_embedding_root"] = default_embedding_root
changed = True
if changed:
library.setdefault("created_at", self._current_timestamp())
library["updated_at"] = self._current_timestamp()
return changed
def _migrate_setting_keys(self) -> None:
"""Migrate legacy camelCase setting keys to snake_case"""
key_migrations = {
@@ -111,25 +333,36 @@ class SettingsManager:
logger.info("Migration completed")
def _auto_set_default_roots(self):
"""Auto set default root paths if only one folder is present and default is empty."""
"""Auto set default root paths when only one folder is present and the current default is unset or not among the options."""
folder_paths = self.settings.get('folder_paths', {})
updated = False
# loras
loras = folder_paths.get('loras', [])
if isinstance(loras, list) and len(loras) == 1 and not self.settings.get('default_lora_root'):
self.settings['default_lora_root'] = loras[0]
updated = True
if isinstance(loras, list) and len(loras) == 1:
current_lora_root = self.settings.get('default_lora_root')
if current_lora_root not in loras:
self.settings['default_lora_root'] = loras[0]
updated = True
# checkpoints
checkpoints = folder_paths.get('checkpoints', [])
if isinstance(checkpoints, list) and len(checkpoints) == 1 and not self.settings.get('default_checkpoint_root'):
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True
if isinstance(checkpoints, list) and len(checkpoints) == 1:
current_checkpoint_root = self.settings.get('default_checkpoint_root')
if current_checkpoint_root not in checkpoints:
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True
# embeddings
embeddings = folder_paths.get('embeddings', [])
if isinstance(embeddings, list) and len(embeddings) == 1 and not self.settings.get('default_embedding_root'):
self.settings['default_embedding_root'] = embeddings[0]
updated = True
if isinstance(embeddings, list) and len(embeddings) == 1:
current_embedding_root = self.settings.get('default_embedding_root')
if current_embedding_root not in embeddings:
self.settings['default_embedding_root'] = embeddings[0]
updated = True
if updated:
self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_root'),
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
default_embedding_root=self.settings.get('default_embedding_root'),
)
self._save_settings()
def _check_environment_variables(self) -> None:
@@ -160,6 +393,14 @@ class SettingsManager:
def set(self, key: str, value: Any) -> None:
"""Set setting value and save"""
self.settings[key] = value
if key == 'folder_paths' and isinstance(value, Mapping):
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == 'default_lora_root':
self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root':
self._update_active_library_entry(default_checkpoint_root=str(value))
elif key == 'default_embedding_root':
self._update_active_library_entry(default_embedding_root=str(value))
self._save_settings()
def delete(self, key: str) -> None:
@@ -177,6 +418,227 @@ class SettingsManager:
except Exception as e:
logger.error(f"Error saving settings: {e}")
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries."""
libraries = self.settings.get("libraries", {})
return copy.deepcopy(libraries)
def get_active_library_name(self) -> str:
"""Return the currently active library name."""
libraries = self.settings.get("libraries", {})
active_name = self.settings.get("active_library")
if active_name and active_name in libraries:
return active_name
if libraries:
return next(iter(libraries.keys()))
return "default"
def get_active_library(self) -> Dict[str, Any]:
"""Return a copy of the active library configuration."""
libraries = self.settings.get("libraries", {})
active_name = self.get_active_library_name()
return copy.deepcopy(libraries.get(active_name, {}))
def activate_library(self, library_name: str) -> None:
"""Activate a library by name and refresh dependent services."""
libraries = self.settings.get("libraries", {})
if library_name not in libraries:
raise KeyError(f"Library '{library_name}' does not exist")
current_active = self.get_active_library_name()
if current_active == library_name:
# Ensure root settings stay in sync even if already active
self._sync_active_library_to_root(save=False)
self._save_settings()
self._notify_library_change(library_name)
return
self.settings["active_library"] = library_name
self._sync_active_library_to_root(save=False)
self._save_settings()
self._notify_library_change(library_name)
def upsert_library(
self,
library_name: str,
*,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
) -> Dict[str, Any]:
"""Create or update a library definition."""
name = library_name.strip()
if not name:
raise ValueError("Library name cannot be empty")
if folder_paths is not None:
self._validate_folder_paths(name, folder_paths)
libraries = self.settings.setdefault("libraries", {})
existing = libraries.get(name, {})
payload = self._build_library_payload(
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"),
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"),
default_checkpoint_root=(
default_checkpoint_root
if default_checkpoint_root is not None
else existing.get("default_checkpoint_root")
),
default_embedding_root=(
default_embedding_root
if default_embedding_root is not None
else existing.get("default_embedding_root")
),
metadata=metadata if metadata is not None else existing.get("metadata"),
base=existing,
)
libraries[name] = payload
if activate or not self.settings.get("active_library"):
self.settings["active_library"] = name
self._sync_active_library_to_root(save=False)
self._save_settings()
if self.settings.get("active_library") == name:
self._notify_library_change(name)
return payload
def create_library(
self,
library_name: str,
*,
folder_paths: Mapping[str, Iterable[str]],
default_lora_root: str = "",
default_checkpoint_root: str = "",
default_embedding_root: str = "",
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
) -> Dict[str, Any]:
"""Create a new library entry."""
libraries = self.settings.get("libraries", {})
if library_name in libraries:
raise ValueError(f"Library '{library_name}' already exists")
return self.upsert_library(
library_name,
folder_paths=folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_embedding_root=default_embedding_root,
metadata=metadata,
activate=activate,
)
def rename_library(self, old_name: str, new_name: str) -> None:
"""Rename an existing library."""
libraries = self.settings.get("libraries", {})
if old_name not in libraries:
raise KeyError(f"Library '{old_name}' does not exist")
new_name_stripped = new_name.strip()
if not new_name_stripped:
raise ValueError("New library name cannot be empty")
if new_name_stripped in libraries:
raise ValueError(f"Library '{new_name_stripped}' already exists")
libraries[new_name_stripped] = libraries.pop(old_name)
if self.settings.get("active_library") == old_name:
self.settings["active_library"] = new_name_stripped
active_name = new_name_stripped
else:
active_name = self.settings.get("active_library")
self._sync_active_library_to_root(save=False)
self._save_settings()
if active_name == new_name_stripped:
self._notify_library_change(new_name_stripped)
def delete_library(self, library_name: str) -> None:
"""Remove a library definition."""
libraries = self.settings.get("libraries", {})
if library_name not in libraries:
raise KeyError(f"Library '{library_name}' does not exist")
if len(libraries) == 1:
raise ValueError("At least one library must remain")
was_active = self.settings.get("active_library") == library_name
libraries.pop(library_name)
if was_active:
new_active = next(iter(libraries.keys()))
self.settings["active_library"] = new_active
self._sync_active_library_to_root(save=False)
self._save_settings()
if was_active:
self._notify_library_change(self.settings["active_library"])
def update_active_library_paths(
self,
folder_paths: Mapping[str, Iterable[str]],
*,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
) -> None:
"""Update folder paths for the active library."""
active_name = self.get_active_library_name()
self.upsert_library(
active_name,
folder_paths=folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_embedding_root=default_embedding_root,
activate=True,
)
def _notify_library_change(self, library_name: str) -> None:
"""Notify dependent services that the active library changed."""
libraries = self.settings.get("libraries", {})
library_config = libraries.get(library_name, {})
library_snapshot = copy.deepcopy(library_config)
try:
from ..config import config # Local import to avoid circular dependency
config.apply_library_settings(library_snapshot)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to apply library settings to config: %s", exc)
try:
from .service_registry import ServiceRegistry # type: ignore
for service_name in (
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"recipe_scanner",
):
service = ServiceRegistry.get_service_sync(service_name)
if service and hasattr(service, "on_library_changed"):
try:
service.on_library_changed()
except Exception as service_exc: # pragma: no cover - defensive logging
logger.debug(
"Service %s failed to handle library change: %s",
service_name,
service_exc,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to notify services about library change: %s", exc)
def get_download_path_template(self, model_type: str) -> str:
"""Get download path template for specific model type

View File

@@ -16,6 +16,8 @@ class WebSocketManager:
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
# Add progress tracking dictionary
self._download_progress: Dict[str, Dict] = {}
# Cache last initialization progress payloads
self._last_init_progress: Dict[str, Dict] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict] = None
self._auto_organize_lock = asyncio.Lock()
@@ -39,8 +41,10 @@ class WebSocketManager:
ws = web.WebSocketResponse()
await ws.prepare(request)
self._init_websockets.add(ws)
try:
await self._send_cached_init_progress(ws)
async for msg in ws:
if msg.type == web.WSMsgType.ERROR:
logger.error(f'Init WebSocket error: {ws.exception()}')
@@ -102,23 +106,53 @@ class WebSocketManager:
async def broadcast_init_progress(self, data: Dict):
"""Broadcast initialization progress to connected clients"""
payload = dict(data) if data else {}
if 'stage' not in payload:
payload['stage'] = 'processing'
if 'progress' not in payload:
payload['progress'] = 0
if 'details' not in payload:
payload['details'] = 'Processing...'
key = self._get_init_progress_key(payload)
self._last_init_progress[key] = dict(payload)
if not self._init_websockets:
return
# Ensure data has all required fields
if 'stage' not in data:
data['stage'] = 'processing'
if 'progress' not in data:
data['progress'] = 0
if 'details' not in data:
data['details'] = 'Processing...'
for ws in self._init_websockets:
stale_clients = []
for ws in list(self._init_websockets):
try:
await ws.send_json(data)
await ws.send_json(payload)
except Exception as e:
logger.error(f"Error sending initialization progress: {e}")
stale_clients.append(ws)
for ws in stale_clients:
self._init_websockets.discard(ws)
async def _send_cached_init_progress(self, ws: web.WebSocketResponse) -> None:
"""Send cached initialization progress payloads to a new client"""
if not self._last_init_progress:
return
for payload in list(self._last_init_progress.values()):
try:
await ws.send_json(payload)
except Exception as e:
logger.debug(f'Error sending cached initialization progress: {e}')
def _get_init_progress_key(self, data: Dict) -> str:
"""Return a stable key for caching initialization progress payloads"""
page_type = data.get('pageType')
if page_type:
return f'page:{page_type}'
scanner_type = data.get('scanner_type')
if scanner_type:
return f'scanner:{scanner_type}'
return 'global'
async def broadcast_download_progress(self, download_id: str, data: Dict):
"""Send progress update to specific download client"""
# Store simplified progress data in memory (only progress percentage)
@@ -202,4 +236,5 @@ class WebSocketManager:
return str(uuid4())
# Global instance
ws_manager = WebSocketManager()
ws_manager = WebSocketManager()

View File

@@ -1,13 +1,19 @@
from __future__ import annotations
import logging
import os
import asyncio
import json
import time
import logging
import os
import shutil
from typing import Any, Dict
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import (
ExampleImagePathResolver,
ensure_library_root_exists,
uses_library_scoped_folders,
)
from ..utils.metadata_manager import MetadataManager
from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
@@ -74,6 +80,22 @@ class _DownloadProgress(dict):
snapshot['failed_models'] = list(self['failed_models'])
return snapshot
def _model_directory_has_files(path: str) -> bool:
"""Return True when the provided directory exists and contains entries."""
if not path or not os.path.isdir(path):
return False
try:
with os.scandir(path) as entries:
for _ in entries:
return True
except OSError:
return False
return False
class DownloadManager:
"""Manages downloading example images for models."""
@@ -84,6 +106,12 @@ class DownloadManager:
self._ws_manager = ws_manager
self._state_lock = state_lock or asyncio.Lock()
def _resolve_output_dir(self, library_name: str | None = None) -> str:
base_path = settings.get('example_images_path')
if not base_path:
return ''
return ensure_library_root_exists(library_name)
async def start_download(self, options: dict):
"""Start downloading example images for models."""
@@ -98,9 +126,9 @@ class DownloadManager:
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2))
output_dir = settings.get('example_images_path')
base_path = settings.get('example_images_path')
if not output_dir:
if not base_path:
error_msg = 'Example images path not configured in settings'
if auto_mode:
logger.debug(error_msg)
@@ -110,7 +138,10 @@ class DownloadManager:
}
raise DownloadConfigurationError(error_msg)
os.makedirs(output_dir, exist_ok=True)
active_library = settings.get_active_library_name()
output_dir = self._resolve_output_dir(active_library)
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
self._progress.reset()
self._progress['status'] = 'running'
@@ -118,9 +149,31 @@ class DownloadManager:
self._progress['end_time'] = None
progress_file = os.path.join(output_dir, '.download_progress.json')
if os.path.exists(progress_file):
progress_source = progress_file
if uses_library_scoped_folders():
legacy_root = settings.get('example_images_path') or ''
legacy_progress = os.path.join(legacy_root, '.download_progress.json') if legacy_root else ''
if legacy_progress and os.path.exists(legacy_progress) and not os.path.exists(progress_file):
try:
os.makedirs(output_dir, exist_ok=True)
shutil.move(legacy_progress, progress_file)
logger.info(
"Migrated legacy download progress file '%s' to '%s'",
legacy_progress,
progress_file,
)
except OSError as exc:
logger.warning(
"Failed to migrate download progress file from '%s' to '%s': %s",
legacy_progress,
progress_file,
exc,
)
progress_source = legacy_progress
if os.path.exists(progress_source):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
with open(progress_source, 'r', encoding='utf-8') as f:
saved_progress = json.load(f)
self._progress['processed_models'] = set(saved_progress.get('processed_models', []))
self._progress['failed_models'] = set(saved_progress.get('failed_models', []))
@@ -143,11 +196,17 @@ class DownloadManager:
output_dir,
optimize,
model_types,
delay
delay,
active_library,
)
)
snapshot = self._progress.snapshot()
except ExampleImagesDownloadError:
# Re-raise our own exception types without wrapping
self._is_downloading = False
self._download_task = None
raise
except Exception as e:
self._is_downloading = False
self._download_task = None
@@ -208,7 +267,14 @@ class DownloadManager:
'message': 'Download resumed'
}
async def _download_all_example_images(self, output_dir, optimize, model_types, delay):
async def _download_all_example_images(
self,
output_dir,
optimize,
model_types,
delay,
library_name,
):
"""Download example images for all models."""
downloader = await get_downloader()
@@ -246,8 +312,13 @@ class DownloadManager:
for i, (scanner_type, model, scanner) in enumerate(all_models):
# Main logic for processing model is here, but actual operations are delegated to other classes
was_remote_download = await self._process_model(
scanner_type, model, scanner,
output_dir, optimize, downloader
scanner_type,
model,
scanner,
output_dir,
optimize,
downloader,
library_name,
)
# Update progress
@@ -289,7 +360,16 @@ class DownloadManager:
self._is_downloading = False
self._download_task = None
async def _process_model(self, scanner_type, model, scanner, output_dir, optimize, downloader):
async def _process_model(
self,
scanner_type,
model,
scanner,
output_dir,
optimize,
downloader,
library_name,
):
"""Process a single model download."""
# Check if download is paused
@@ -316,20 +396,35 @@ class DownloadManager:
logger.debug(f"Skipping known failed model: {model_name}")
return False
model_dir = ExampleImagePathResolver.get_model_folder(model_hash, library_name)
existing_files = _model_directory_has_files(model_dir)
# Skip if already processed AND directory exists with files
if model_hash in self._progress['processed_models']:
model_dir = os.path.join(output_dir, model_hash)
has_files = os.path.exists(model_dir) and any(os.listdir(model_dir))
if has_files:
if existing_files:
logger.debug(f"Skipping already processed model: {model_name}")
return False
else:
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Remove from processed models since we need to reprocess
self._progress['processed_models'].discard(model_hash)
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Remove from processed models since we need to reprocess
self._progress['processed_models'].discard(model_hash)
if existing_files and model_hash not in self._progress['processed_models']:
logger.debug(
"Model folder already populated for %s, marking as processed without download",
model_name,
)
self._progress['processed_models'].add(model_hash)
return False
if not model_dir:
logger.warning(
"Unable to resolve example images folder for model %s (%s)",
model_name,
model_hash,
)
return False
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
os.makedirs(model_dir, exist_ok=True)
# First check for local example images - local processing doesn't need delay
@@ -345,14 +440,20 @@ class DownloadManager:
self._progress['processed_models'].add(model_hash)
return False # Return False to indicate no remote download happened
full_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
civitai_payload = (full_model or {}).get('civitai') if full_model else None
civitai_payload = civitai_payload or {}
# If no local images, try to download from remote
elif model.get('civitai') and model.get('civitai', {}).get('images'):
images = model.get('civitai', {}).get('images', [])
if civitai_payload.get('images'):
images = civitai_payload.get('images', [])
success, is_stale = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, images, model_dir, optimize, downloader
)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
@@ -363,16 +464,18 @@ class DownloadManager:
updated_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
updated_civitai = (updated_model or {}).get('civitai') if updated_model else None
updated_civitai = updated_civitai or {}
if updated_model and updated_model.get('civitai', {}).get('images'):
if updated_civitai.get('images'):
# Retry download with updated metadata
updated_images = updated_model.get('civitai', {}).get('images', [])
updated_images = updated_civitai.get('images', [])
success, _ = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, updated_images, model_dir, optimize, downloader
)
self._progress['refreshed_models'].add(model_hash)
# Mark as processed if successful, or as failed if unsuccessful after refresh
if success:
self._progress['processed_models'].add(model_hash)
@@ -381,13 +484,13 @@ class DownloadManager:
if model_hash in self._progress['refreshed_models']:
self._progress['failed_models'].add(model_hash)
logger.info(f"Marking model {model_name} as failed after metadata refresh")
return True # Return True to indicate a remote download happened
else:
# No civitai data or images available, mark as failed to avoid future attempts
self._progress['failed_models'].add(model_hash)
logger.debug(f"No civitai images available for model {model_name}, marking as failed")
# Save progress periodically
if self._progress['completed'] % 10 == 0 or self._progress['completed'] == self._progress['total'] - 1:
self._save_progress(output_dir)
@@ -452,13 +555,15 @@ class DownloadManager:
if not model_hashes:
raise DownloadConfigurationError('Missing model_hashes parameter')
output_dir = settings.get('example_images_path')
base_path = settings.get('example_images_path')
if not base_path:
raise DownloadConfigurationError('Example images path not configured in settings')
active_library = settings.get_active_library_name()
output_dir = self._resolve_output_dir(active_library)
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
os.makedirs(output_dir, exist_ok=True)
self._progress.reset()
self._progress['total'] = len(model_hashes)
self._progress['status'] = 'running'
@@ -475,7 +580,8 @@ class DownloadManager:
output_dir,
optimize,
model_types,
delay
delay,
active_library,
)
async with self._state_lock:
@@ -494,7 +600,15 @@ class DownloadManager:
await self._broadcast_progress(status='error', extra={'error': str(e)})
raise ExampleImagesDownloadError(str(e)) from e
async def _download_specific_models_example_images_sync(self, model_hashes, output_dir, optimize, model_types, delay):
async def _download_specific_models_example_images_sync(
self,
model_hashes,
output_dir,
optimize,
model_types,
delay,
library_name,
):
"""Download example images for specific models only - synchronous version."""
downloader = await get_downloader()
@@ -535,8 +649,13 @@ class DownloadManager:
for i, (scanner_type, model, scanner) in enumerate(models_to_process):
# Force process this model regardless of previous status
was_successful = await self._process_specific_model(
scanner_type, model, scanner,
output_dir, optimize, downloader
scanner_type,
model,
scanner,
output_dir,
optimize,
downloader,
library_name,
)
if was_successful:
@@ -588,7 +707,16 @@ class DownloadManager:
# No need to close any sessions since we use the global downloader
pass
async def _process_specific_model(self, scanner_type, model, scanner, output_dir, optimize, downloader):
async def _process_specific_model(
self,
scanner_type,
model,
scanner,
output_dir,
optimize,
downloader,
library_name,
):
"""Process a specific model for forced download, ignoring previous download status."""
# Check if download is paused
@@ -610,8 +738,15 @@ class DownloadManager:
self._progress['current_model'] = f"{model_name} ({model_hash[:8]})"
await self._broadcast_progress(status='running')
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
model_dir = ExampleImagePathResolver.get_model_folder(model_hash, library_name)
if not model_dir:
logger.warning(
"Unable to resolve example images folder for model %s (%s)",
model_name,
model_hash,
)
return False
os.makedirs(model_dir, exist_ok=True)
# First check for local example images - local processing doesn't need delay
@@ -627,51 +762,61 @@ class DownloadManager:
self._progress['processed_models'].add(model_hash)
return False # Return False to indicate no remote download happened
full_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
civitai_payload = (full_model or {}).get('civitai') if full_model else None
civitai_payload = civitai_payload or {}
# If no local images, try to download from remote
elif model.get('civitai') and model.get('civitai', {}).get('images'):
images = model.get('civitai', {}).get('images', [])
if civitai_payload.get('images'):
images = civitai_payload.get('images', [])
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, images, model_dir, optimize, downloader
)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
model_hash, model_name, scanner_type, scanner, self._progress
)
# Get the updated model data
updated_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
if updated_model and updated_model.get('civitai', {}).get('images'):
updated_civitai = (updated_model or {}).get('civitai') if updated_model else None
updated_civitai = updated_civitai or {}
if updated_civitai.get('images'):
# Retry download with updated metadata
updated_images = updated_model.get('civitai', {}).get('images', [])
updated_images = updated_civitai.get('images', [])
success, _, additional_failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, updated_images, model_dir, optimize, downloader
)
# Combine failed images from both attempts
failed_images.extend(additional_failed_images)
self._progress['refreshed_models'].add(model_hash)
# For forced downloads, remove failed images from metadata
if failed_images:
# Create a copy of images excluding failed ones
await self._remove_failed_images_from_metadata(
model_hash, model_name, failed_images, scanner
)
# Mark as processed
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
self._progress['processed_models'].add(model_hash)
return True # Return True to indicate a remote download happened
else:
logger.debug(f"No civitai images available for model {model_name}")
return False
except Exception as e:

View File

@@ -4,6 +4,10 @@ import sys
import subprocess
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.example_images_paths import (
get_model_folder,
get_model_relative_path,
)
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__)
@@ -41,8 +45,12 @@ class ExampleImagesFileManager:
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = os.path.abspath(model_folder) # Get absolute path
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve example images folder for this model.'
}, status=500)
# Path validation: ensure model_folder is under example_images_path
if not model_folder.startswith(os.path.abspath(example_images_path)):
@@ -109,8 +117,13 @@ class ExampleImagesFileManager:
}, status=400)
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve example images folder for this model'
}, status=500)
# Check if folder exists
if not os.path.exists(model_folder):
return web.json_response({
@@ -128,9 +141,10 @@ class ExampleImagesFileManager:
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
relative_path = get_model_relative_path(model_hash)
files.append({
'name': file,
'path': f'/example_images_static/{model_hash}/{file}',
'path': f'/example_images_static/{relative_path}/{file}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
@@ -176,8 +190,13 @@ class ExampleImagesFileManager:
})
# Construct folder path for this model
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'has_images': False,
'error': 'Failed to resolve example images folder for this model'
})
# Check if folder exists
if not os.path.exists(model_folder) or not os.path.isdir(model_folder):
return web.json_response({

View File

@@ -95,21 +95,35 @@ class MetadataUpdater:
@staticmethod
async def get_updated_model(model_hash, scanner):
"""Get updated model data
Args:
model_hash: SHA256 hash of the model
scanner: Scanner instance
Returns:
dict: Updated model data or None if not found
"""
"""Load the most recent metadata for a model identified by hash."""
cache = await scanner.get_cached_data()
target = None
for item in cache.raw_data:
if item.get('sha256') == model_hash:
return item
return None
target = item
break
if not target:
return None
file_path = target.get('file_path')
if not file_path:
return target
model_cls = getattr(scanner, 'model_class', None)
if model_cls is None:
metadata, should_skip = await MetadataManager.load_metadata(file_path)
else:
metadata, should_skip = await MetadataManager.load_metadata(file_path, model_cls)
if should_skip or metadata is None:
return target
rich_metadata = metadata.to_dict()
rich_metadata.setdefault('folder', target.get('folder', ''))
return rich_metadata
@staticmethod
async def update_metadata_from_local_examples(model_hash, model, scanner_type, scanner, model_dir):
"""Update model metadata with local example image information

View File

@@ -5,6 +5,7 @@ import re
import json
from ..services.settings_manager import settings
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import iter_library_roots
from ..utils.metadata_manager import MetadataManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
@@ -19,29 +20,35 @@ class ExampleImagesMigration:
@staticmethod
async def check_and_run_migrations():
"""Check if migrations are needed and run them in background"""
example_images_path = settings.get('example_images_path')
if not example_images_path or not os.path.exists(example_images_path):
root = settings.get('example_images_path')
if not root or not os.path.exists(root):
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
return
# Check current version from progress file
current_version = 0
progress_file = os.path.join(example_images_path, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
current_version = progress_data.get('naming_version', 0)
except Exception as e:
logger.error(f"Failed to load progress file for migration check: {e}")
# If current version is less than target version, start migration
if current_version < CURRENT_NAMING_VERSION:
logger.info(f"Starting example images naming migration from v{current_version} to v{CURRENT_NAMING_VERSION}")
# Start migration in background task
asyncio.create_task(
ExampleImagesMigration.run_migrations(example_images_path, current_version, CURRENT_NAMING_VERSION)
)
for library_name, library_path in iter_library_roots():
if not library_path or not os.path.exists(library_path):
continue
current_version = 0
progress_file = os.path.join(library_path, '.download_progress.json')
if os.path.exists(progress_file):
try:
with open(progress_file, 'r', encoding='utf-8') as f:
progress_data = json.load(f)
current_version = progress_data.get('naming_version', 0)
except Exception as e:
logger.error(f"Failed to load progress file for migration check: {e}")
if current_version < CURRENT_NAMING_VERSION:
logger.info(
"Starting example images naming migration from v%s to v%s for library '%s'",
current_version,
CURRENT_NAMING_VERSION,
library_name,
)
asyncio.create_task(
ExampleImagesMigration.run_migrations(library_path, current_version, CURRENT_NAMING_VERSION)
)
@staticmethod
async def run_migrations(example_images_path, from_version, to_version):

View File

@@ -0,0 +1,221 @@
"""Utility helpers for resolving example image storage paths."""
from __future__ import annotations
import logging
import os
import re
import shutil
from typing import Iterable, List, Optional, Tuple
from ..services.settings_manager import settings
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
logger = logging.getLogger(__name__)
def _get_configured_libraries() -> List[str]:
"""Return configured library names if multi-library support is enabled."""
libraries = settings.get("libraries")
if isinstance(libraries, dict) and libraries:
return list(libraries.keys())
return []
def get_example_images_root() -> str:
"""Return the root directory configured for example images."""
root = settings.get("example_images_path") or ""
return os.path.abspath(root) if root else ""
def uses_library_scoped_folders() -> bool:
"""Return True when example images should be separated per library."""
libraries = _get_configured_libraries()
return len(libraries) > 1
def sanitize_library_name(library_name: Optional[str]) -> str:
"""Return a filesystem safe library name."""
name = library_name or settings.get_active_library_name() or "default"
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
return safe_name or "default"
def get_library_root(library_name: Optional[str] = None) -> str:
"""Return the directory where a library's example images should live."""
root = get_example_images_root()
if not root:
return ""
if uses_library_scoped_folders():
return os.path.join(root, sanitize_library_name(library_name))
return root
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
"""Ensure the example image directory for a library exists and return it."""
library_root = get_library_root(library_name)
if library_root:
os.makedirs(library_root, exist_ok=True)
return library_root
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the folder path for a model's example images."""
if not model_hash:
return ""
library_root = ensure_library_root_exists(library_name)
if not library_root:
return ""
normalized_hash = (model_hash or "").lower()
resolved_folder = os.path.join(library_root, normalized_hash)
if uses_library_scoped_folders():
legacy_root = get_example_images_root()
legacy_folder = os.path.join(legacy_root, normalized_hash)
if os.path.exists(legacy_folder) and not os.path.exists(resolved_folder):
try:
os.makedirs(library_root, exist_ok=True)
shutil.move(legacy_folder, resolved_folder)
logger.info(
"Migrated legacy example images folder '%s' to '%s'", legacy_folder, resolved_folder
)
except OSError as exc:
logger.error(
"Failed to migrate example images from '%s' to '%s': %s",
legacy_folder,
resolved_folder,
exc,
)
return legacy_folder
return resolved_folder
class ExampleImagePathResolver:
"""Convenience wrapper exposing example image path helpers."""
@staticmethod
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the example image folder for a model, migrating legacy paths."""
return get_model_folder(model_hash, library_name)
@staticmethod
def get_library_root(library_name: Optional[str] = None) -> str:
"""Return the configured library root for example images."""
return get_library_root(library_name)
@staticmethod
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
"""Ensure the library root exists before writing files."""
return ensure_library_root_exists(library_name)
@staticmethod
def get_model_relative_path(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the relative path to a model folder from the static mount point."""
return get_model_relative_path(model_hash, library_name)
def get_model_relative_path(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the relative URL path from the static mount to a model folder."""
root = get_example_images_root()
folder = get_model_folder(model_hash, library_name)
if not root or not folder:
return ""
try:
relative = os.path.relpath(folder, root)
except ValueError:
return ""
return relative.replace("\\", "/")
def iter_library_roots() -> Iterable[Tuple[str, str]]:
"""Yield configured library names and their resolved filesystem roots."""
root = get_example_images_root()
if not root:
return []
libraries = _get_configured_libraries()
if uses_library_scoped_folders():
results: List[Tuple[str, str]] = []
if libraries:
for library in libraries:
results.append((library, get_library_root(library)))
else:
# Fall back to the active library to avoid skipping migrations/cleanup
active = settings.get_active_library_name() or "default"
results.append((active, get_library_root(active)))
return results
active = settings.get_active_library_name() or "default"
return [(active, root)]
def is_hash_folder(name: str) -> bool:
"""Return True if the provided name looks like a model hash folder."""
return bool(_HEX_PATTERN.fullmatch(name or ""))
def is_valid_example_images_root(folder_path: str) -> bool:
"""Check whether a folder looks like a dedicated example images root."""
try:
items = os.listdir(folder_path)
except OSError:
return False
for item in items:
item_path = os.path.join(folder_path, item)
if item == ".download_progress.json" and os.path.isfile(item_path):
continue
if os.path.isdir(item_path):
if is_hash_folder(item):
continue
if item == "_deleted":
# Allow cleanup staging folders
continue
# When multi-library mode is active we expect nested hash folders
if uses_library_scoped_folders():
if _library_folder_has_only_hash_dirs(item_path):
continue
return False
return True
def _library_folder_has_only_hash_dirs(path: str) -> bool:
"""Return True when a library subfolder only contains hash folders or metadata files."""
try:
for entry in os.listdir(path):
entry_path = os.path.join(path, entry)
if entry == ".download_progress.json" and os.path.isfile(entry_path):
continue
if entry == "_deleted" and os.path.isdir(entry_path):
continue
if not os.path.isdir(entry_path) or not is_hash_folder(entry):
return False
except OSError:
return False
return True

View File

@@ -7,6 +7,7 @@ from aiohttp import web
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from ..utils.example_images_paths import get_model_folder, get_model_relative_path
from .example_images_metadata import MetadataUpdater
from ..utils.metadata_manager import MetadataManager
@@ -346,7 +347,9 @@ class ExampleImagesProcessor:
)
# Create model folder
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
raise ExampleImagesImportError('Failed to resolve model folder for example images')
os.makedirs(model_folder, exist_ok=True)
imported_files = []
@@ -383,7 +386,7 @@ class ExampleImagesProcessor:
# Add to imported files list
imported_files.append({
'name': new_filename,
'path': f'/example_images_static/{model_hash}/{new_filename}',
'path': f'/example_images_static/{get_model_relative_path(model_hash)}/{new_filename}',
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
})
@@ -497,7 +500,12 @@ class ExampleImagesProcessor:
}, status=404)
# Find and delete the actual file
model_folder = os.path.join(example_images_path, model_hash)
model_folder = get_model_folder(model_hash)
if not model_folder:
return web.json_response({
'success': False,
'error': 'Failed to resolve model folder for example images'
}, status=500)
file_deleted = False
if os.path.exists(model_folder):

View File

@@ -4,7 +4,7 @@ import logging
from typing import Optional
from io import BytesIO
import os
from PIL import Image
from PIL import Image, PngImagePlugin
logger = logging.getLogger(__name__)
@@ -86,9 +86,10 @@ class ExifUtils:
# For PNG, try to update parameters directly
if img_format == 'PNG':
# We'll save with parameters in the PNG info
info_dict = {'parameters': metadata}
img.save(image_path, format='PNG', pnginfo=info_dict)
# Use PngInfo instead of plain dictionary
png_info = PngImagePlugin.PngInfo()
png_info.add_text("parameters", metadata)
img.save(image_path, format='PNG', pnginfo=png_info)
return image_path
# For WebP format, use PIL's exif parameter directly

View File

@@ -0,0 +1,84 @@
"""Utilities for locating and migrating the LoRA Manager settings file."""
from __future__ import annotations
import logging
import os
import shutil
from typing import Optional
from platformdirs import user_config_dir
APP_NAME = "ComfyUI-LoRA-Manager"
_LOGGER = logging.getLogger(__name__)
def get_project_root() -> str:
"""Return the root directory of the project repository."""
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def get_legacy_settings_path() -> str:
"""Return the legacy location of ``settings.json`` within the project tree."""
return os.path.join(get_project_root(), "settings.json")
def get_settings_dir(create: bool = True) -> str:
"""Return the user configuration directory for the application.
Args:
create: Whether to create the directory if it does not already exist.
Returns:
The absolute path to the user configuration directory.
"""
config_dir = user_config_dir(APP_NAME, appauthor=False)
if create:
os.makedirs(config_dir, exist_ok=True)
return config_dir
def get_settings_file_path(create_dir: bool = True) -> str:
"""Return the path to ``settings.json`` in the user configuration directory."""
return os.path.join(get_settings_dir(create=create_dir), "settings.json")
def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
"""Ensure the settings file resides in the user configuration directory.
If a legacy ``settings.json`` is detected in the project root it is migrated to
the platform-specific user configuration folder. The caller receives the path
to the settings file irrespective of whether a migration was needed.
Args:
logger: Optional logger used for migration messages. Falls back to a
module level logger when omitted.
Returns:
The absolute path to ``settings.json`` in the user configuration folder.
"""
logger = logger or _LOGGER
target_path = get_settings_file_path(create_dir=True)
legacy_path = get_legacy_settings_path()
if os.path.exists(legacy_path) and not os.path.exists(target_path):
try:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
shutil.move(legacy_path, target_path)
logger.info("Migrated settings.json to %s", target_path)
except Exception as exc: # pragma: no cover - defensive fallback path
logger.warning("Failed to move legacy settings.json: %s", exc)
try:
shutil.copy2(legacy_path, target_path)
logger.info("Copied legacy settings.json to %s", target_path)
except Exception as copy_exc: # pragma: no cover - defensive fallback path
logger.error("Could not migrate settings.json: %s", copy_exc)
return target_path

View File

@@ -11,11 +11,21 @@ from ..config import config
from ..services.service_registry import ServiceRegistry
# Check if running in standalone mode
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
# Define constants locally to avoid dependency on conditional imports
MODELS = "models"
LORAS = "loras"
if not standalone_mode:
from ..metadata_collector.metadata_registry import MetadataRegistry
from ..metadata_collector.constants import MODELS, LORAS
# Import constants from metadata_collector to ensure consistency, but we have fallbacks defined above
try:
from ..metadata_collector.constants import MODELS as _MODELS, LORAS as _LORAS
MODELS = _MODELS
LORAS = _LORAS
except ImportError:
pass # Use the local definitions
logger = logging.getLogger(__name__)

View File

@@ -189,6 +189,9 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
if model_type == 'embedding':
formatted_path = formatted_path.replace(' ', '_')
return formatted_path
def remove_empty_dirs(path):

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.4"
version = "0.9.6"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -13,7 +13,8 @@ dependencies = [
"toml",
"natsort",
"GitPython",
"aiosqlite"
"aiosqlite",
"platformdirs"
]
[project.urls]

View File

@@ -1,15 +0,0 @@
{
"loras": "<lora:pp-enchanted-whimsy:0.9> <lora:ral-frctlgmtry_flux:1> <lora:pp-storybook_rank2_bf16:0.8>",
"gen_params": {
"prompt": "in the style of ppWhimsy, ral-frctlgmtry, ppstorybook,Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"negative_prompt": "",
"steps": "25",
"sampler": "dpmpp_2m",
"scheduler": "beta",
"cfg": "1",
"seed": "48",
"guidance": 3.5,
"size": "896x1152",
"clip_skip": "2"
}
}

View File

@@ -1,314 +0,0 @@
{
"6": {
"inputs": {
"text": [
"46",
0
],
"clip": [
"58",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"31",
0
],
"vae": [
"39",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"27": {
"inputs": {
"width": 896,
"height": 1152,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "EmptySD3LatentImage"
}
},
"31": {
"inputs": {
"seed": 44,
"steps": 25,
"cfg": 1,
"sampler_name": "dpmpp_2m",
"scheduler": "beta",
"denoise": 1,
"model": [
"58",
0
],
"positive": [
"35",
0
],
"negative": [
"33",
0
],
"latent_image": [
"27",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"33": {
"inputs": {
"text": "",
"clip": [
"58",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"35": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"6",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"37": {
"inputs": {
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"38": {
"inputs": {
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "clip_l.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"39": {
"inputs": {
"vae_name": "flux1\\ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"46": {
"inputs": {
"string1": [
"59",
0
],
"string2": [
"51",
0
],
"delimiter": ","
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"50": {
"inputs": {
"images": [
"8",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "Preview Image"
}
},
"51": {
"inputs": {
"string": "Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"strip_newlines": true
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "positive"
}
},
"58": {
"inputs": {
"text": "<lora:pp-enchanted-whimsy:0.9><lora:ral-frctlgmtry_flux:1><lora:pp-storybook_rank2_bf16:0.8>",
"loras": [
{
"name": "pp-enchanted-whimsy",
"strength": "0.90",
"active": false
},
{
"name": "ral-frctlgmtry_flux",
"strength": "0.85",
"active": false
},
{
"name": "pp-storybook_rank2_bf16",
"strength": 0.8,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"model": [
"37",
0
],
"clip": [
"38",
0
]
},
"class_type": "Lora Loader (LoraManager)",
"_meta": {
"title": "Lora Loader (LoraManager)"
}
},
"59": {
"inputs": {
"group_mode": "",
"toggle_trigger_words": [
{
"text": "ppstorybook",
"active": false
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
}
],
"orinalMessage": "ppstorybook",
"trigger_words": [
"58",
2
]
},
"class_type": "TriggerWord Toggle (LoraManager)",
"_meta": {
"title": "TriggerWord Toggle (LoraManager)"
}
},
"61": {
"inputs": {
"add_noise": "enable",
"noise_seed": 1111423448930884,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": 0,
"end_at_step": 10000,
"return_with_leftover_noise": "disable"
},
"class_type": "KSamplerAdvanced",
"_meta": {
"title": "KSampler (Advanced)"
}
},
"62": {
"inputs": {
"sigmas": [
"63",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"63": {
"inputs": {
"scheduler": "normal",
"steps": 20,
"denoise": 1
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"64": {
"inputs": {
"seed": 1089899258710474,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"65": {
"inputs": {
"text": ",Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"anything": [
"46",
0
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
}
}

View File

@@ -1,258 +0,0 @@
{
"id": 649516,
"name": "Cynthia -シロナ - Pokemon Diamond and Pearl - PDXL LORA",
"description": "<p><strong>Warning: Without Adetailer eyes are fucked (rainbow color and artefact)</strong></p><p><span style=\"color:rgb(193, 194, 197)\">Trained on </span><a target=\"_blank\" rel=\"ugc\" href=\"https://civitai.com/models/257749/horsefucker-diffusion-v6-xl\"><strong>Pony Diffusion V6 XL</strong></a> with 63 pictures.<br />Best result with weight between : 0.8-1.</p><p><span style=\"color:rgb(193, 194, 197)\">Basic prompts : </span><code>1girl, cynthia \\(pokemon\\), blonde hair, hair over one eye, very long hair, grey eyes, eyelashes, hair ornament</code> <br /><span style=\"color:rgb(193, 194, 197)\">Outfit prompts : </span><code>fur collar, black coat, fur-trimmed coat, long sleeves, black pants, black shirt, high heels</code></p><p>Reviews are really appreciated, i love to see the community use my work, that's why I share it.<br />If you like my work, you can tip me <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/konan49773\"><strong>here.</strong></a></p><p>Got a specific request ? I'm open for commission on my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/konan49773/commissions\"><strong>kofi</strong></a> or<strong> </strong><a target=\"_blank\" rel=\"ugc\" href=\"https://www.fiverr.com/konanai/create-lora-model-for-you\"><strong>fiverr gig</strong></a> *! If you provide enough data, OCs are accepted</p>",
"allowNoCredit": true,
"allowCommercialUse": [
"Image",
"RentCivit"
],
"allowDerivatives": true,
"allowDifferentLicense": true,
"type": "LORA",
"minor": false,
"sfwOnly": false,
"poi": false,
"nsfw": false,
"nsfwLevel": 29,
"availability": "Public",
"cosmetic": null,
"supportsGeneration": true,
"stats": {
"downloadCount": 811,
"favoriteCount": 0,
"thumbsUpCount": 175,
"thumbsDownCount": 0,
"commentCount": 4,
"ratingCount": 0,
"rating": 0,
"tippedAmountCount": 10
},
"creator": {
"username": "Konan",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/7cd552a1-60fe-4baf-a0e4-f7d5d5381711/width=96/Konan.jpeg"
},
"tags": [
"anime",
"character",
"cynthia",
"woman",
"pokemon",
"pokegirl"
],
"modelVersions": [
{
"id": 726676,
"index": 0,
"name": "v1.0",
"baseModel": "Pony",
"createdAt": "2024-08-16T01:13:16.099Z",
"publishedAt": "2024-08-16T01:14:44.984Z",
"status": "Published",
"availability": "Public",
"nsfwLevel": 29,
"trainedWords": [
"1girl, cynthia \\(pokemon\\), blonde hair, hair over one eye, very long hair, grey eyes, eyelashes, hair ornament",
"fur collar, black coat, fur-trimmed coat, long sleeves, black pants, black shirt, high heels"
],
"covered": true,
"stats": {
"downloadCount": 811,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 175,
"thumbsDownCount": 0
},
"files": [
{
"id": 641092,
"sizeKB": 56079.65234375,
"name": "CynthiaXL.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2024-08-16T01:17:19.087Z",
"metadata": {
"format": "SafeTensor"
},
"hashes": {},
"downloadUrl": "https://civitai.com/api/download/models/726676",
"primary": true
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/b346d757-2b59-4aeb-9f09-3bee2724519d/width=1248/24511993.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UqNc==RP.9s+~pxvIst7kWWBWBjY%MWBt7WB",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/fc132ac0-cc1c-4b68-a1d7-5b97b0996ac2/width=1248/24511997.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UMGSS+?tTw.60MIX9cbb~WxHRRR-NEtLRiR%",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/7b3237d1-e672-466a-85d0-cc5dd42ab130/width=1160/24512001.jpeg",
"nsfwLevel": 4,
"width": 1160,
"height": 1696,
"hash": "U9NA6f~o00%h00wvIYt74:ER-=D%5600DiE1",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/ccd7d11d-4fa9-4434-85a1-fb999312e60d/width=1248/24511991.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UyNTg.j?~qxu?aoLRkj]%MfkM{jZaya}a#ax",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/1743be6d-7fe5-4b55-9f19-c931618fa259/width=1248/24511996.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "UGOC~n^+?w~6Tx_4oM^$yYEkMds74:9F#*xY",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/91693c98-d037-4489-882c-100eb26019a0/width=1160/24512010.jpeg",
"nsfwLevel": 4,
"width": 1160,
"height": 1696,
"hash": "UJI}kp^-Kl%hXAIX4;Nf^+M|9GRP0Mt8%L%2",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/49c7a294-ac5b-4832-98e5-2acd0f1a8782/width=1248/24512017.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "UML;8Qn|9G%3mnWA4nWFMf%N?Hae~qog-oNF",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/d7b442f2-6ead-4a7a-9578-54d9ec2ff148/width=1248/24512015.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "UPGR#kt8xw%M0LWC9bWC?wxtR*NLM^jrxWM|",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/d840f1e9-3dd3-4531-b83a-1ba2c6b7feaa/width=1160/24512004.jpeg",
"nsfwLevel": 8,
"width": 1160,
"height": 1696,
"hash": "ULNm1i_39wi^*I%hDiM_tlo#xuV?^kNIxCs,",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/520387ae-c176-43e3-92bd-5cd2a672475e/width=1248/24512012.jpeg",
"nsfwLevel": 4,
"width": 1248,
"height": 1824,
"hash": "URM%l.%M.9Ip~poIkExu_3V@M|xuD%oJM{D*",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/9ea28b94-f326-4776-83ff-851cc203c627/width=1248/24511988.jpeg",
"nsfwLevel": 1,
"width": 1248,
"height": 1824,
"hash": "U-PZloog_Nxut6j]WXWB-;j?IVa#ofaxj]j]",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
},
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/2e749dbb-7d5a-48f1-8e29-fea5022a5fe9/width=1248/24522268.jpeg",
"nsfwLevel": 16,
"width": 1248,
"height": 1824,
"hash": "UPLgtm9Z0z=|0yRRE2-A9rWAoNE1~DwOr=t7",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/726676"
}
]
}

View File

@@ -1,401 +0,0 @@
{
"6": {
"inputs": {
"text": [
"301",
0
],
"clip": [
"299",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"13",
1
],
"vae": [
"10",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"10": {
"inputs": {
"vae_name": "flux1\\ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"11": {
"inputs": {
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "ViT-L-14-TEXT-detail-improved-hiT-GmP-TE-only-HF.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"13": {
"inputs": {
"noise": [
"147",
0
],
"guider": [
"22",
0
],
"sampler": [
"16",
0
],
"sigmas": [
"17",
0
],
"latent_image": [
"48",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"16": {
"inputs": {
"sampler_name": "dpmpp_2m"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"17": {
"inputs": {
"scheduler": "beta",
"steps": [
"246",
0
],
"denoise": 1,
"model": [
"28",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"22": {
"inputs": {
"model": [
"28",
0
],
"conditioning": [
"29",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"28": {
"inputs": {
"max_shift": 1.1500000000000001,
"base_shift": 0.5,
"width": [
"48",
1
],
"height": [
"48",
2
],
"model": [
"299",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "ModelSamplingFlux"
}
},
"29": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"6",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"48": {
"inputs": {
"resolution": "832x1216 (0.68)",
"batch_size": 1,
"width_override": 0,
"height_override": 0
},
"class_type": "SDXLEmptyLatentSizePicker+",
"_meta": {
"title": "🔧 SDXL Empty Latent Size Picker"
}
},
"65": {
"inputs": {
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"147": {
"inputs": {
"noise_seed": 651532572596956
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"148": {
"inputs": {
"wildcard_text": "__some-prompts__",
"populated_text": "A surreal digital artwork showcases a forward-thinking inventor captivated by his intricate mechanical creation through a large magnifying glass. Viewed from an unconventional perspective, the scene reveals an eccentric assembly of gears, springs, and brass instruments within his workshop. Soft, ethereal light radiates from the invention, casting enigmatic shadows on the walls as time appears to bend around its metallic form, invoking a sense of curiosity, wonder, and exhilaration in discovery.",
"mode": "fixed",
"seed": 553084268162351,
"Select to add Wildcard": "Select the Wildcard to add to the text"
},
"class_type": "ImpactWildcardProcessor",
"_meta": {
"title": "ImpactWildcardProcessor"
}
},
"151": {
"inputs": {
"text": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"191": {
"inputs": {
"text": "A cinematic, oil painting masterpiece captures the essence of impressionistic surrealism, inspired by Claude Monet. A mysterious woman in a flowing crimson dress stands at the edge of a tranquil lake, where lily pads shimmer under an ethereal, golden twilight. The waters surface reflects a dreamlike sky, its swirling hues of violet and sapphire melting together like liquid light. The thick, expressive brushstrokes lend depth to the scene, evoking a sense of nostalgia and quiet longing, as if the world itself is caught between reality and a fleeting dream. \nA mesmerizing oil painting masterpiece inspired by Salvador Dalí, blending surrealism with post-impressionist texture. A lone violinist plays atop a melting clock tower, his form distorted by the passage of time. The sky is a cascade of swirling, liquid oranges and deep blues, where floating staircases spiral endlessly into the horizon. The impasto technique gives depth and movement to the surreal elements, making time itself feel fluid, as if the world is dissolving into a dream. \nA stunning impressionistic oil painting evokes the spirit of Edvard Munch, capturing a solitary figure standing on a rain-soaked street, illuminated by the glow of flickering gas lamps. The swirling, chaotic strokes of deep blues and fiery reds reflect the turbulence of emotion, while the blurred reflections in the wet cobblestone suggest a merging of past and present. The faceless figure, draped in a dark overcoat, seems lost in thought, embodying the ephemeral nature of memory and time. \nA breathtaking oil painting masterpiece, inspired by Gustav Klimt, presents a celestial ballroom where faceless dancers swirl in an eternal waltz beneath a gilded, star-speckled sky. Their golden garments shimmer with intricate patterns, blending into the opulent mosaic floor that seems to stretch into infinity. The dreamlike composition, rich in warm amber and deep sapphire hues, captures an otherworldly elegance, as if the dancers are suspended in a moment that transcends time. \nA visionary oil painting inspired by Marc Chagall depicts a dreamlike cityscape where gravity ceases to exist. A couple floats above a crimson-tinted town, their forms dissolving into the swirling strokes of a vast, cerulean sky. The buildings below twist and bend in rhythmic motion, their windows glowing like tiny stars. The thick, textured brushwork conveys a sense of weightlessness and wonder, as if love itself has defied the laws of the universe. \nAn impressionistic oil painting in the style of J.M.W. Turner, depicting a ghostly ship sailing through a sea of swirling golden mist. The waves crash and dissolve into abstract, fiery strokes of orange and deep indigo, blurring the line between ocean and sky. The ship appears almost ethereal, as if drifting between worlds, lost in the ever-changing tides of memory and myth. The dynamic brushstrokes capture the relentless power of nature and the fleeting essence of time. \nA captivating oil painting masterpiece, infused with surrealist impressionism, portrays a grand library where books float midair, their pages unraveling into ribbons of light. The towering shelves twist into the heavens, vanishing into an infinite, starry void. A lone scholar, illuminated by the glow of a suspended lantern, reaches for a book that seems to pulse with life. The scene pulses with mystery, where the impasto textures bring depth to the interplay between knowledge and dreams. \nA luminous impressionistic oil painting captures the melancholic beauty of an abandoned carnival, its faded carousel horses frozen mid-gallop beneath a sky of swirling lavender and gold. The wind carries fragments of forgotten laughter through the empty fairground, where scattered ticket stubs and crumbling banners whisper tales of joy long past. The thick, textured brushstrokes blend nostalgia with an eerie dreamlike quality, as if the carnival exists only in the echoes of memory. \nA surreal oil painting in the spirit of René Magritte, featuring a towering lighthouse that emits not light, but cascading waterfalls from its peak. The swirling sky, painted in deep midnight blues, is punctuated by glowing, crescent moons that defy gravity. A lone figure stands at the waters edge, gazing up in quiet contemplation, as if caught between wonder and the unknown. The paintings rich textures and luminous colors create an enigmatic, dreamlike landscape. \nA striking impressionistic oil painting, reminiscent of Van Gogh, portrays a lone traveler on a winding cobblestone path, their silhouette bathed in the golden glow of lantern-lit cherry blossoms. The petals swirl through the night air like glowing embers, blending with the deep, rhythmic strokes of a star-filled indigo sky. The scene captures a feeling of wistful solitude, as if the traveler is walking not only through the city, but through the fleeting nature of time itself."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"203": {
"inputs": {
"string1": [
"289",
0
],
"string2": [
"293",
0
],
"delimiter": ", "
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"208": {
"inputs": {
"file_path": "",
"dictionary_name": "[filename]",
"label": "TextBatch",
"mode": "automatic",
"index": 0,
"multiline_text": [
"191",
0
]
},
"class_type": "Text Load Line From File",
"_meta": {
"title": "Text Load Line From File"
}
},
"226": {
"inputs": {
"images": [
"8",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "Preview Image"
}
},
"246": {
"inputs": {
"value": 25
},
"class_type": "INTConstant",
"_meta": {
"title": "Steps"
}
},
"289": {
"inputs": {
"group_mode": true,
"toggle_trigger_words": [
{
"text": "bo-exposure",
"active": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
}
],
"orinalMessage": "bo-exposure",
"trigger_words": [
"299",
2
]
},
"class_type": "TriggerWord Toggle (LoraManager)",
"_meta": {
"title": "TriggerWord Toggle (LoraManager)"
}
},
"293": {
"inputs": {
"input": 1,
"text1": [
"208",
0
],
"text2": [
"151",
0
]
},
"class_type": "easy textSwitch",
"_meta": {
"title": "Text Switch"
}
},
"297": {
"inputs": {
"text": ""
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
},
"298": {
"inputs": {
"anything": [
"297",
0
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
},
"299": {
"inputs": {
"text": "<lora:boFLUX Double Exposure Magic v2:0.8> <lora:FluxDFaeTasticDetails:0.65>",
"loras": [
{
"name": "boFLUX Double Exposure Magic v2",
"strength": 0.8,
"active": true
},
{
"name": "FluxDFaeTasticDetails",
"strength": 0.65,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"model": [
"65",
0
],
"clip": [
"11",
0
],
"lora_stack": [
"297",
0
]
},
"class_type": "Lora Loader (LoraManager)",
"_meta": {
"title": "Lora Loader (LoraManager)"
}
},
"301": {
"inputs": {
"string": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura.",
"strip_newlines": true
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "String Constant Multiline"
}
}
}

91
refs/version.json Normal file
View File

@@ -0,0 +1,91 @@
{
"id": 1255556,
"modelId": 1117241,
"name": "v1.0",
"createdAt": "2025-01-08T06:13:08.839Z",
"updatedAt": "2025-01-08T06:28:54.156Z",
"status": "Published",
"publishedAt": "2025-01-08T06:28:54.155Z",
"trainedWords": ["in the style of ppWhimsy"],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": "Standard",
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
"stats": {
"downloadCount": 210,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 26
},
"model": {
"name": "Enchanted Whimsy style (Flux)",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1160774,
"sizeKB": 38828.8125,
"name": "pp-enchanted-whimsy.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-01-08T06:16:27.731Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "40CAF049",
"AutoV2": "3202778C3E",
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
"CRC32": "69DCD953",
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
"AutoV3": "BF82986F8597"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1255556"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"type": "image",
"metadata": {
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
"size": 702313,
"width": 832,
"height": 1216
},
"minor": false,
"poi": false,
"meta": {
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1255556",
"creator": {
"username": "PixelPawsAI",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
}
}

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=7.4
pytest-cov>=4.1

View File

@@ -10,3 +10,4 @@ natsort
GitPython
aiosqlite
beautifulsoup4
platformdirs

7
scripts/api.js Normal file
View File

@@ -0,0 +1,7 @@
export const api = {
fetchApi: (...args) => fetch(...args),
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler),
};
export default api;

12
scripts/app.js Normal file
View File

@@ -0,0 +1,12 @@
export const app = {
canvas: { ds: { scale: 1 } },
extensionManager: {
toast: {
add: () => {},
},
},
registerExtension: () => {},
graphToPrompt: async () => ({ workflow: { nodes: new Map() } }),
};
export default app;

205
scripts/run_frontend_coverage.js Executable file
View File

@@ -0,0 +1,205 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdirSync, rmSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const coverageRoot = path.join(repoRoot, 'coverage');
const v8OutputDir = path.join(coverageRoot, '.v8');
const frontendCoverageDir = path.join(coverageRoot, 'frontend');
rmSync(v8OutputDir, { recursive: true, force: true });
rmSync(frontendCoverageDir, { recursive: true, force: true });
mkdirSync(v8OutputDir, { recursive: true });
mkdirSync(frontendCoverageDir, { recursive: true });
const vitestCli = path.join(repoRoot, 'node_modules', 'vitest', 'vitest.mjs');
if (!existsSync(vitestCli)) {
console.error('Failed to locate Vitest CLI at', vitestCli);
console.error('Try reinstalling frontend dependencies with `npm install`.');
process.exit(1);
}
const env = { ...process.env, NODE_V8_COVERAGE: v8OutputDir };
const spawnOptions = { stdio: 'inherit', env };
const result = spawnSync(process.execPath, [vitestCli, 'run'], spawnOptions);
if (result.error) {
console.error('Failed to execute Vitest:', result.error.message);
process.exit(result.status ?? 1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
const fileCoverage = collectCoverageFromV8(v8OutputDir, repoRoot);
writeCoverageOutputs(fileCoverage, frontendCoverageDir, repoRoot);
printSummary(fileCoverage);
rmSync(v8OutputDir, { recursive: true, force: true });
function collectCoverageFromV8(v8Dir, rootDir) {
const coverageMap = new Map();
const files = readdirSync(v8Dir).filter((file) => file.endsWith('.json'));
for (const file of files) {
const reportPath = path.join(v8Dir, file);
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
if (!Array.isArray(report.result)) {
continue;
}
for (const script of report.result) {
const filePath = normalizeFilePath(script.url, rootDir);
if (!filePath) {
continue;
}
if (!filePath.startsWith('static/')) {
continue;
}
if (!filePath.endsWith('.js')) {
continue;
}
const absolutePath = path.join(rootDir, filePath);
let lineMap = coverageMap.get(filePath);
if (!lineMap) {
lineMap = new Map();
coverageMap.set(filePath, lineMap);
}
const source = readFileSync(absolutePath, 'utf8');
const lineOffsets = calculateLineOffsets(source);
for (const fn of script.functions ?? []) {
for (const range of fn.ranges ?? []) {
if (range.startOffset === range.endOffset) {
continue;
}
const count = typeof range.count === 'number' ? range.count : 0;
const startLine = findLineNumber(range.startOffset, lineOffsets);
const endLine = findLineNumber(Math.max(range.endOffset - 1, range.startOffset), lineOffsets);
for (let line = startLine; line <= endLine; line += 1) {
const current = lineMap.get(line);
if (current === undefined || count > current) {
lineMap.set(line, count);
}
}
}
}
}
}
return coverageMap;
}
function normalizeFilePath(url, rootDir) {
if (!url) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'file:') {
return null;
}
const absolute = fileURLToPath(parsed);
const relative = path.relative(rootDir, absolute);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
}
return relative.replace(/\\/g, '/');
} catch {
if (url.startsWith(rootDir)) {
return url.slice(rootDir.length + 1).replace(/\\/g, '/');
}
return null;
}
}
function calculateLineOffsets(content) {
const offsets = [0];
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
offsets.push(index + 1);
}
}
offsets.push(content.length);
return offsets;
}
function findLineNumber(offset, lineOffsets) {
let low = 0;
let high = lineOffsets.length - 1;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (lineOffsets[mid] <= offset) {
low = mid;
} else {
high = mid - 1;
}
}
return low + 1;
}
function writeCoverageOutputs(coverageMap, outputDir, rootDir) {
const summary = {
total: { lines: { total: 0, covered: 0, pct: 100 } },
files: {},
};
let lcovContent = '';
for (const [relativePath, lineMap] of [...coverageMap.entries()].sort()) {
const lines = [...lineMap.entries()].sort((a, b) => a[0] - b[0]);
const total = lines.length;
const covered = lines.filter(([, count]) => count > 0).length;
const pct = total === 0 ? 100 : (covered / total) * 100;
summary.files[relativePath] = {
lines: {
total,
covered,
pct,
},
};
summary.total.lines.total += total;
summary.total.lines.covered += covered;
const absolute = path.join(rootDir, relativePath);
lcovContent += 'TN:\n';
lcovContent += `SF:${absolute.replace(/\\/g, '/')}\n`;
for (const [line, count] of lines) {
lcovContent += `DA:${line},${count}\n`;
}
lcovContent += `LF:${total}\n`;
lcovContent += `LH:${covered}\n`;
lcovContent += 'end_of_record\n';
}
summary.total.lines.pct = summary.total.lines.total === 0
? 100
: (summary.total.lines.covered / summary.total.lines.total) * 100;
writeFileSync(path.join(outputDir, 'coverage-summary.json'), JSON.stringify(summary, null, 2));
writeFileSync(path.join(outputDir, 'lcov.info'), lcovContent, 'utf8');
}
function printSummary(coverageMap) {
let totalLines = 0;
let totalCovered = 0;
for (const lineMap of coverageMap.values()) {
const lines = lineMap.size;
const covered = [...lineMap.values()].filter((count) => count > 0).length;
totalLines += lines;
totalCovered += covered;
}
const pct = totalLines === 0 ? 100 : (totalCovered / totalLines) * 100;
console.log(`\nFrontend coverage: ${totalCovered}/${totalLines} lines (${pct.toFixed(2)}%)`);
}

View File

@@ -14,4 +14,4 @@
"C:/path/to/another/embeddings_folder"
]
}
}
}

View File

@@ -1,11 +1,11 @@
from pathlib import Path
import os
import sys
import json
from py.middleware.cache_middleware import cache_control
from py.utils.settings_paths import ensure_settings_file, get_settings_dir
# Set environment variable to indicate standalone mode
os.environ["COMFYUI_LORA_MANAGER_STANDALONE"] = "1"
os.environ["LORA_MANAGER_STANDALONE"] = "1"
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
@@ -32,7 +32,7 @@ class MockFolderPaths:
@staticmethod
def get_folder_paths(folder_name):
# Load paths from settings.json
settings_path = os.path.join(os.path.dirname(__file__), 'settings.json')
settings_path = ensure_settings_file()
try:
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
@@ -159,7 +159,7 @@ class StandaloneServer:
self.app.router.add_get('/', self.handle_status)
# Add static route for example images if the path exists in settings
settings_path = os.path.join(os.path.dirname(__file__), 'settings.json')
settings_path = ensure_settings_file(logger)
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
@@ -219,16 +219,19 @@ from py.lora_manager import LoraManager
def validate_settings():
"""Validate that settings.json exists and has required configuration"""
settings_path = os.path.join(os.path.dirname(__file__), 'settings.json')
settings_path = ensure_settings_file(logger)
if not os.path.exists(settings_path):
logger.error("=" * 80)
logger.error("CONFIGURATION ERROR: settings.json file not found!")
logger.error("")
logger.error("Expected location: %s", settings_path)
logger.error("")
logger.error("To run in standalone mode, you need to create a settings.json file.")
logger.error("Please follow these steps:")
logger.error("")
logger.error("1. Copy the provided settings.json.example file to create a new file")
logger.error(" named settings.json in the comfyui-lora-manager folder")
logger.error(" named settings.json inside the LoRA Manager settings folder:")
logger.error(" %s", get_settings_dir())
logger.error("")
logger.error("2. Edit settings.json to include your correct model folder paths")
logger.error(" and CivitAI API key")
@@ -276,121 +279,7 @@ class StandaloneLoraManager(LoraManager):
# Store app in a global-like location for compatibility
sys.modules['server'].PromptServer.instance = server_instance
added_targets = set() # Track already added target paths
# Add static routes for each lora root
for idx, root in enumerate(config.loras_roots, start=1):
if not os.path.exists(root):
logger.warning(f"Lora root path does not exist: {root}")
continue
preview_path = f'/loras_static/root{idx}/preview'
# Check if this root is a link path in the mappings
real_root = root
for target, link in config._path_mappings.items():
if os.path.normpath(link) == os.path.normpath(root):
# If so, route should point to the target (real path)
real_root = target
break
# Normalize and standardize path display for consistency
display_root = real_root.replace('\\', '/')
# Add static route for original path - use the normalized path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {display_root}")
# Record route mapping with normalized path
config.add_route_mapping(real_root, preview_path)
added_targets.add(os.path.normpath(real_root))
# Add static routes for each checkpoint root
for idx, root in enumerate(config.base_models_roots, start=1):
if not os.path.exists(root):
logger.warning(f"Checkpoint root path does not exist: {root}")
continue
preview_path = f'/checkpoints_static/root{idx}/preview'
# Check if this root is a link path in the mappings
real_root = root
for target, link in config._path_mappings.items():
if os.path.normpath(link) == os.path.normpath(root):
# If so, route should point to the target (real path)
real_root = target
break
# Normalize and standardize path display for consistency
display_root = real_root.replace('\\', '/')
# Add static route for original path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {display_root}")
# Record route mapping
config.add_route_mapping(real_root, preview_path)
added_targets.add(os.path.normpath(real_root))
# Add static routes for each embedding root
for idx, root in enumerate(getattr(config, "embeddings_roots", []), start=1):
if not os.path.exists(root):
logger.warning(f"Embedding root path does not exist: {root}")
continue
preview_path = f'/embeddings_static/root{idx}/preview'
real_root = root
for target, link in config._path_mappings.items():
if os.path.normpath(link) == os.path.normpath(root):
real_root = target
break
display_root = real_root.replace('\\', '/')
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {display_root}")
config.add_route_mapping(real_root, preview_path)
added_targets.add(os.path.normpath(real_root))
# Add static routes for symlink target paths that aren't already covered
link_idx = {
'lora': 1,
'checkpoint': 1,
'embedding': 1
}
for target_path, link_path in config._path_mappings.items():
norm_target = os.path.normpath(target_path)
if norm_target not in added_targets:
# Determine if this is a checkpoint, lora, or embedding link based on path
is_checkpoint = any(os.path.normpath(cp_root) in os.path.normpath(link_path) for cp_root in config.base_models_roots)
is_checkpoint = is_checkpoint or any(os.path.normpath(cp_root) in norm_target for cp_root in config.base_models_roots)
is_embedding = any(os.path.normpath(emb_root) in os.path.normpath(link_path) for emb_root in getattr(config, "embeddings_roots", []))
is_embedding = is_embedding or any(os.path.normpath(emb_root) in norm_target for emb_root in getattr(config, "embeddings_roots", []))
if is_checkpoint:
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
link_idx["checkpoint"] += 1
elif is_embedding:
route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview'
link_idx["embedding"] += 1
else:
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
link_idx["lora"] += 1
# Display path with forward slashes for consistency
display_target = target_path.replace('\\', '/')
try:
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
logger.info(f"Added static route for link target {route_path} -> {display_target}")
config.add_route_mapping(target_path, route_path)
added_targets.add(norm_target)
except Exception as e:
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
continue
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path)
@@ -405,6 +294,7 @@ class StandaloneLoraManager(LoraManager):
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
from py.routes.preview_routes import PreviewRoutes
from py.routes.stats_routes import StatsRoutes
from py.services.websocket_manager import ws_manager
@@ -422,6 +312,7 @@ class StandaloneLoraManager(LoraManager):
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)

View File

@@ -32,6 +32,7 @@ html, body {
--text-muted: #6c757d;
--card-bg: #ffffff;
--border-color: #e0e0e0;
--header-height: 48px;
/* Color Components */
--lora-accent-l: 68%;

View File

@@ -73,12 +73,20 @@
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
font-size: 0.9rem;
}
.nav-item:hover {
.nav-item:hover,
.nav-item:focus-visible {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.nav-item:hover i,
.nav-item:hover span,
.nav-item:focus-visible i,
.nav-item:focus-visible span {
color: inherit;
}
.nav-item.active {
@@ -273,4 +281,4 @@
.header-search {
flex: 1;
}
}
}

View File

@@ -2,10 +2,10 @@
.modal {
display: none;
position: fixed;
top: 48px; /* Start below the header */
top: var(--header-height, 48px); /* Start below the header */
left: 0;
width: 100%;
height: calc(100% - 48px); /* Adjust height to exclude header */
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
z-index: var(--z-modal);
overflow: auto; /* Change from hidden to auto to allow scrolling */
@@ -23,7 +23,7 @@ body.modal-open {
position: relative;
max-width: 800px;
height: auto;
max-height: calc(90vh);
max-height: calc(100vh - var(--header-height, 48px) - 5.5rem); /* Subtract header height and modal margins */
margin: 1rem auto; /* Keep reduced top margin */
background: var(--lora-surface);
border-radius: var(--border-radius-base);

View File

@@ -23,6 +23,48 @@
max-width: 650px; /* Further increased from 600px for more space */
}
.settings-header {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.settings-header h2 {
margin: 0;
}
.settings-open-location-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.settings-open-location-button:hover,
.settings-open-location-button:focus-visible {
opacity: 1;
background-color: rgba(var(--border-color-rgb, 148, 163, 184), 0.2);
outline: none;
}
.settings-open-location-button i {
font-size: 1em;
}
.settings-open-location-button:focus-visible {
box-shadow: 0 0 0 2px rgba(var(--border-color-rgb, 148, 163, 184), 0.6);
}
/* Settings Links */
.settings-links {
margin-top: var(--space-3);

View File

@@ -211,6 +211,20 @@
border: 1px solid var(--lora-border);
}
#recipeModal .modal-content {
display: flex;
flex-direction: column;
}
#recipeModal .modal-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
/* Top Section: Preview and Gen Params */
.recipe-top-section {
display: grid;
@@ -474,9 +488,10 @@
/* Bottom Section: Resources */
.recipe-bottom-section {
max-height: 320px;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}

View File

@@ -5,7 +5,7 @@ import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
// Initialize the Checkpoints page
class CheckpointsPageManager {
export class CheckpointsPageManager {
constructor() {
// Initialize page controls
this.pageControls = createPageControls(MODEL_TYPES.CHECKPOINT);
@@ -31,17 +31,21 @@ class CheckpointsPageManager {
async initialize() {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
console.log('Checkpoints Manager initialized');
}
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
export async function initializeCheckpointsPage() {
// Initialize core application
await appCore.initialize();
// Initialize checkpoints page
const checkpointsPage = new CheckpointsPageManager();
await checkpointsPage.initialize();
});
return checkpointsPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeCheckpointsPage);

View File

@@ -28,6 +28,7 @@ export class BulkContextMenu extends BaseContextMenu {
// Update button visibility based on model type
const addTagsItem = this.menu.querySelector('[data-action="add-tags"]');
const setBaseModelItem = this.menu.querySelector('[data-action="set-base-model"]');
const setContentRatingItem = this.menu.querySelector('[data-action="set-content-rating"]');
const sendToWorkflowAppendItem = this.menu.querySelector('[data-action="send-to-workflow-append"]');
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
@@ -63,6 +64,9 @@ export class BulkContextMenu extends BaseContextMenu {
if (setBaseModelItem) {
setBaseModelItem.style.display = 'flex'; // Base model editing is available for all model types
}
if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
}
}
updateSelectedCountHeader() {
@@ -86,6 +90,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'set-base-model':
bulkManager.showBulkBaseModelModal();
break;
case 'set-content-rating':
bulkManager.showBulkContentRatingSelector();
break;
case 'send-to-workflow-append':
bulkManager.sendAllModelsToWorkflow(false);
break;

View File

@@ -2,6 +2,7 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../util
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { bulkManager } from '../../managers/BulkManager.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -11,6 +12,7 @@ export const ModelContextMenuMixin = {
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
});
// Level buttons
@@ -18,41 +20,70 @@ export const ModelContextMenuMixin = {
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const mode = this.nsfwSelector.dataset.mode || 'single';
if (mode === 'bulk') {
let bulkFilePaths = [];
if (this.nsfwSelector.dataset.bulkFilePaths) {
try {
bulkFilePaths = JSON.parse(this.nsfwSelector.dataset.bulkFilePaths);
} catch (error) {
console.warn('Failed to parse bulk file paths for content rating', error);
}
}
const success = await bulkManager.setBulkContentRating(level, bulkFilePaths);
if (success) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
return;
}
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
} catch (error) {
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"], .context-menu-item[data-action="set-content-rating"]')) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
});
},
resetNSFWSelectorState() {
if (!this.nsfwSelector) return;
delete this.nsfwSelector.dataset.bulkFilePaths;
delete this.nsfwSelector.dataset.mode;
delete this.nsfwSelector.dataset.cardPath;
},
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
@@ -60,35 +91,37 @@ export const ModelContextMenuMixin = {
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
selector.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.mode = 'single';
selector.dataset.cardPath = card.dataset.filepath;
delete selector.dataset.bulkFilePaths;
// Show selector
selector.style.display = 'block';
},

View File

@@ -56,6 +56,8 @@ class InitializationManager {
this.pageType = 'checkpoints';
} else if (path.includes('/loras')) {
this.pageType = 'loras';
} else if (path.includes('/embeddings')) {
this.pageType = 'embeddings';
} else {
// Default to loras if can't determine
this.pageType = 'loras';
@@ -195,6 +197,7 @@ class InitializationManager {
*/
handleProgressUpdate(data) {
if (!data) return;
console.log('Received progress update:', data);
// Check if this update is for our page type
if (data.pageType && data.pageType !== this.pageType) {
@@ -206,7 +209,8 @@ class InitializationManager {
if (!data.pageType && data.scanner_type) {
const scannerTypeToPageType = {
'lora': 'loras',
'checkpoint': 'checkpoints'
'checkpoint': 'checkpoints',
'embedding': 'embeddings'
};
if (scannerTypeToPageType[data.scanner_type] !== this.pageType) {

View File

@@ -420,7 +420,7 @@ export function createModelCard(model, modelType) {
const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map();
const version = previewVersions.get(model.file_path);
const previewUrl = model.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
const versionedPreviewUrl = version ? `${previewUrl}${previewUrl.includes('?') ? '&' : '?'}t=${version}` : previewUrl;
// Determine NSFW warning text based on level with i18n support
let nsfwText = translate('modelCard.nsfw.matureContent', {}, 'Mature Content');
@@ -435,7 +435,18 @@ export function createModelCard(model, modelType) {
// Check if autoplayOnHover is enabled for video previews
const autoplayOnHover = state.global?.settings?.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
const videoAttrs = [
'controls',
'muted',
'loop',
'playsinline',
'preload="none"',
`data-src="${versionedPreviewUrl}"`
];
if (!autoplayOnHover) {
videoAttrs.push('data-autoplay="true"');
}
// Get favorite status from model data
const isFavorite = model.favorite === true;
@@ -473,9 +484,7 @@ export function createModelCard(model, modelType) {
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs} style="pointer-events: none;">
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
}
<div class="card-header">
@@ -514,21 +523,257 @@ export function createModelCard(model, modelType) {
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
// Remove autoplay attribute and pause initially
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause using event attributes
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
if (videoElement) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
return card;
}
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
const VIDEO_LOAD_INTERVAL_MS = 120;
const VIDEO_LOAD_MAX_CONCURRENCY = 2;
let videoLazyObserver = null;
const videoLoadQueue = [];
const queuedVideoElements = new Set();
let activeVideoLoads = 0;
let queueTimer = null;
const scheduleFrame = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (callback) => setTimeout(callback, 16);
function scheduleVideoQueueProcessing(delay = 0) {
if (queueTimer !== null) {
return;
}
queueTimer = setTimeout(() => {
queueTimer = null;
processVideoLoadQueue();
}, delay);
}
function dequeueVideoElement(videoElement) {
if (!queuedVideoElements.has(videoElement)) {
return;
}
queuedVideoElements.delete(videoElement);
const index = videoLoadQueue.indexOf(videoElement);
if (index !== -1) {
videoLoadQueue.splice(index, 1);
}
}
function processVideoLoadQueue() {
if (videoLoadQueue.length === 0) {
return;
}
while (activeVideoLoads < VIDEO_LOAD_MAX_CONCURRENCY && videoLoadQueue.length > 0) {
const videoElement = videoLoadQueue.shift();
queuedVideoElements.delete(videoElement);
if (!videoElement || !videoElement.isConnected || videoElement.dataset.loaded === 'true') {
continue;
}
activeVideoLoads++;
videoElement.dataset.loading = 'true';
scheduleFrame(() => {
try {
loadVideoSource(videoElement);
} finally {
delete videoElement.dataset.loading;
activeVideoLoads--;
if (videoLoadQueue.length > 0) {
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
}
}
});
}
if (videoLoadQueue.length > 0 && queueTimer === null) {
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
}
}
function enqueueVideoElement(videoElement) {
if (!videoElement || videoElement.dataset.loaded === 'true' || videoElement.dataset.loading === 'true') {
return;
}
if (!videoElement.isConnected) {
return;
}
if (queuedVideoElements.has(videoElement)) {
return;
}
queuedVideoElements.add(videoElement);
videoLoadQueue.push(videoElement);
scheduleVideoQueueProcessing();
}
function ensureVideoLazyObserver() {
if (videoLazyObserver) {
return videoLazyObserver;
}
videoLazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
enqueueVideoElement(entry.target);
}
});
}, {
root: null,
rootMargin: VIDEO_LAZY_ROOT_MARGIN,
threshold: 0.01
});
return videoLazyObserver;
}
function cleanupHoverHandlers(videoElement) {
const handlers = videoElement._hoverHandlers;
if (!handlers) return;
const { cardPreview, mouseEnter, mouseLeave } = handlers;
if (cardPreview) {
cardPreview.removeEventListener('mouseenter', mouseEnter);
cardPreview.removeEventListener('mouseleave', mouseLeave);
}
delete videoElement._hoverHandlers;
}
function requestSafePlay(videoElement) {
const playPromise = videoElement.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {});
}
}
function loadVideoSource(videoElement) {
if (!videoElement) {
return false;
}
if (videoLazyObserver) {
try {
videoLazyObserver.unobserve(videoElement);
} catch (error) {
// Ignore observer errors (e.g., element already unobserved)
}
}
if (videoElement.dataset.loaded === 'true' || !videoElement.isConnected) {
return false;
}
const sourceElement = videoElement.querySelector('source');
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
if (!dataSrc) {
return false;
}
// Ensure src attributes are reset before applying
videoElement.removeAttribute('src');
if (sourceElement) {
sourceElement.src = dataSrc;
} else {
videoElement.src = dataSrc;
}
videoElement.load();
videoElement.dataset.loaded = 'true';
if (videoElement.dataset.autoplay === 'true') {
videoElement.setAttribute('autoplay', '');
requestSafePlay(videoElement);
}
return true;
}
export function configureModelCardVideo(videoElement, autoplayOnHover) {
if (!videoElement) return;
dequeueVideoElement(videoElement);
cleanupHoverHandlers(videoElement);
const sourceElement = videoElement.querySelector('source');
const existingSrc = videoElement.dataset.src || sourceElement?.dataset?.src || videoElement.currentSrc;
if (existingSrc && !videoElement.dataset.src) {
videoElement.dataset.src = existingSrc;
}
if (sourceElement && !sourceElement.dataset.src) {
sourceElement.dataset.src = videoElement.dataset.src || sourceElement.src;
}
videoElement.removeAttribute('autoplay');
videoElement.removeAttribute('src');
videoElement.setAttribute('preload', 'none');
videoElement.setAttribute('muted', '');
videoElement.setAttribute('loop', '');
videoElement.setAttribute('playsinline', '');
videoElement.setAttribute('controls', '');
videoElement.dataset.loaded = 'false';
delete videoElement.dataset.loading;
if (sourceElement) {
sourceElement.removeAttribute('src');
if (videoElement.dataset.src) {
sourceElement.dataset.src = videoElement.dataset.src;
}
}
if (!autoplayOnHover) {
videoElement.dataset.autoplay = 'true';
} else {
delete videoElement.dataset.autoplay;
}
const observer = ensureVideoLazyObserver();
observer.observe(videoElement);
// Pause the video until it is either hovered or autoplay kicks in
try {
videoElement.pause();
} catch (err) {
// Ignore pause errors (e.g., if not loaded yet)
}
if (autoplayOnHover) {
const cardPreview = videoElement.closest('.card-preview');
if (cardPreview) {
const mouseEnter = () => {
dequeueVideoElement(videoElement);
loadVideoSource(videoElement);
requestSafePlay(videoElement);
};
const mouseLeave = () => {
videoElement.pause();
videoElement.currentTime = 0;
};
cardPreview.addEventListener('mouseenter', mouseEnter);
cardPreview.addEventListener('mouseleave', mouseLeave);
videoElement._hoverHandlers = { cardPreview, mouseEnter, mouseLeave };
}
}
}
// Add a method to update card appearance based on bulk mode (LoRA only)
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
@@ -567,4 +812,6 @@ export function updateCardsForBulkMode(isBulkMode) {
if (isBulkMode) {
bulkManager.applySelectionState();
}
}
}

View File

@@ -48,10 +48,7 @@ function formatPresetKey(key) {
* @param {string} key - Preset key name to remove
*/
window.removePreset = async function(key) {
const filePath = document.querySelector('#modelModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#modelModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath;
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);

View File

@@ -49,7 +49,8 @@ export class AppCore {
bannerService.initialize();
window.modalManager = modalManager;
window.settingsManager = settingsManager;
window.exampleImagesManager = new ExampleImagesManager();
const exampleImagesManager = new ExampleImagesManager();
window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;

View File

@@ -36,12 +36,18 @@ class EmbeddingsPageManager {
}
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
async function initializeEmbeddingsPage() {
// Initialize core application
await appCore.initialize();
// Initialize embeddings page
const embeddingsPage = new EmbeddingsPageManager();
await embeddingsPage.initialize();
});
return embeddingsPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeEmbeddingsPage);
export { EmbeddingsPageManager, initializeEmbeddingsPage };

View File

@@ -25,7 +25,8 @@ class I18nManager {
'ja': { name: 'Japanese', nativeName: '日本語' },
'ko': { name: 'Korean', nativeName: '한국어' },
'fr': { name: 'French', nativeName: 'Français' },
'es': { name: 'Spanish', nativeName: 'Español' }
'es': { name: 'Spanish', nativeName: 'Español' },
'he': { name: 'Hebrew', nativeName: 'עברית' }
};
this.currentLocale = this.getLanguageFromSettings();
@@ -318,4 +319,4 @@ class I18nManager {
export const i18n = new I18nManager();
// Export for global access (will be attached to window)
export default i18n;
export default i18n;

View File

@@ -6,7 +6,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the LoRA page
class LoraPageManager {
export class LoraPageManager {
constructor() {
// Add bulk mode to state
state.bulkMode = false;
@@ -38,18 +38,22 @@ class LoraPageManager {
async initialize() {
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures();
}
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
export async function initializeLoraPage() {
// Initialize core application
await appCore.initialize();
// Initialize page-specific functionality
const loraPage = new LoraPageManager();
await loraPage.initialize();
});
return loraPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeLoraPage);

View File

@@ -1,4 +1,14 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import {
getStorageItem,
setStorageItem
} from '../utils/storageHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_SHOWN_KEY = 'community_support_banner_shown';
const KO_FI_URL = 'https://ko-fi.com/pixelpawsai';
/**
* Banner Service for managing notification banners
@@ -8,6 +18,8 @@ class BannerService {
this.banners = new Map();
this.container = null;
this.initialized = false;
this.communitySupportBannerTimer = null;
this.communitySupportBannerRegistered = false;
}
/**
@@ -51,6 +63,8 @@ class BannerService {
priority: 1
});
this.prepareCommunitySupportBanner();
this.showActiveBanners();
this.initialized = true;
}
@@ -198,6 +212,90 @@ class BannerService {
setStorageItem('dismissed_banners', []);
location.reload();
}
prepareCommunitySupportBanner() {
if (this.communitySupportBannerTimer) {
clearTimeout(this.communitySupportBannerTimer);
this.communitySupportBannerTimer = null;
}
if (getStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, false)) {
return;
}
const now = Date.now();
let firstSeenAt = getStorageItem(COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, null);
if (typeof firstSeenAt !== 'number') {
firstSeenAt = now;
setStorageItem(COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, firstSeenAt);
}
const availableAt = firstSeenAt + COMMUNITY_SUPPORT_BANNER_DELAY_MS;
const delay = Math.max(availableAt - now, 0);
if (delay === 0) {
this.registerCommunitySupportBanner();
} else {
this.communitySupportBannerTimer = setTimeout(() => {
this.registerCommunitySupportBanner();
}, delay);
}
}
registerCommunitySupportBanner() {
if (this.communitySupportBannerRegistered || getStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, false)) {
return;
}
if (this.communitySupportBannerTimer) {
clearTimeout(this.communitySupportBannerTimer);
this.communitySupportBannerTimer = null;
}
this.communitySupportBannerRegistered = true;
setStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, true);
this.registerBanner(COMMUNITY_SUPPORT_BANNER_ID, {
id: COMMUNITY_SUPPORT_BANNER_ID,
title: translate(
'banners.communitySupport.title',
{},
'Keep LoRA Manager Thriving with Your Support ❤️'
),
content: translate(
'banners.communitySupport.content',
{},
'LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.'
),
actions: [
{
text: translate(
'banners.communitySupport.supportCta',
{},
'Support on Ko-fi'
),
icon: 'fas fa-heart',
url: KO_FI_URL,
type: 'primary'
},
{
text: translate(
'banners.communitySupport.learnMore',
{},
'LM Civitai Extension Tutorial'
),
icon: 'fas fa-book',
url: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)',
type: 'tertiary'
}
],
dismissible: true,
priority: 2
});
this.updateContainerVisibility();
}
}
// Create and export singleton instance

View File

@@ -1,5 +1,5 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -35,7 +35,8 @@ export class BulkManager {
refreshAll: true,
moveAll: true,
autoOrganize: true,
deleteAll: true
deleteAll: true,
setContentRating: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
@@ -44,7 +45,8 @@ export class BulkManager {
refreshAll: true,
moveAll: true,
autoOrganize: true,
deleteAll: true
deleteAll: true,
setContentRating: false
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
@@ -53,7 +55,8 @@ export class BulkManager {
refreshAll: true,
moveAll: false,
autoOrganize: true,
deleteAll: true
deleteAll: true,
setContentRating: true
}
};
}
@@ -850,20 +853,137 @@ export class BulkManager {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const countElement = document.getElementById('bulkBaseModelCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
}
modalManager.showModal('bulkBaseModelModal', null, null, () => {
this.cleanupBulkBaseModelModal();
});
// Initialize the bulk base model interface
this.initializeBulkBaseModelInterface();
}
showBulkContentRatingSelector() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
if (!selector || !currentLevelEl) {
console.warn('NSFW level selector not found');
return;
}
const filePaths = Array.from(state.selectedModels);
selector.dataset.mode = 'bulk';
selector.dataset.bulkFilePaths = JSON.stringify(filePaths);
delete selector.dataset.cardPath;
const selectedCards = Array.from(document.querySelectorAll('.model-card.selected'));
const levels = new Set();
selectedCards.forEach((card) => {
let level = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
if (typeof metaData.preview_nsfw_level === 'number') {
level = metaData.preview_nsfw_level;
}
} catch (error) {
console.warn('Failed to parse metadata for card', error);
}
if (!level && card.dataset.nsfwLevel) {
const parsed = parseInt(card.dataset.nsfwLevel, 10);
if (!Number.isNaN(parsed)) {
level = parsed;
}
}
levels.add(level);
});
let highlightLevel = null;
if (levels.size === 1) {
highlightLevel = levels.values().next().value;
currentLevelEl.textContent = getNSFWLevelName(highlightLevel);
} else {
currentLevelEl.textContent = translate('modals.contentRating.multiple', {}, 'Multiple values');
}
selector.querySelectorAll('.nsfw-level-btn').forEach((btn) => {
const btnLevel = parseInt(btn.dataset.level, 10);
if (highlightLevel !== null && btnLevel === highlightLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
const finalX = Math.max((viewportWidth - selectorRect.width) / 2, 0);
const finalY = Math.max((viewportHeight - selectorRect.height) / 2, 0);
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
selector.style.display = 'block';
}
async setBulkContentRating(level, filePaths = null) {
const targets = Array.isArray(filePaths) ? filePaths : Array.from(state.selectedModels);
if (!targets || targets.length === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return false;
}
const totalCount = targets.length;
const levelName = getNSFWLevelName(level);
state.loadingManager.showSimpleLoading(translate('toast.models.bulkContentRatingUpdating', { count: totalCount }));
let successCount = 0;
let failureCount = 0;
try {
const apiClient = getModelApiClient();
for (const filePath of targets) {
try {
await apiClient.saveModelMetadata(filePath, { preview_nsfw_level: level });
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set content rating for ${filePath}:`, error);
}
}
} finally {
state.loadingManager.hideSimpleLoading();
}
if (successCount === totalCount) {
showToast('toast.models.bulkContentRatingSet', { count: successCount, level: levelName }, 'success');
} else if (successCount > 0) {
showToast('toast.models.bulkContentRatingPartial', {
success: successCount,
failed: failureCount,
level: levelName
}, 'warning');
} else {
showToast('toast.models.bulkContentRatingFailed', {}, 'error');
}
return successCount > 0;
}
/**
* Initialize bulk base model interface
*/

View File

@@ -5,12 +5,15 @@ import { resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { i18n } from '../i18n/index.js';
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
export class SettingsManager {
constructor() {
this.initialized = false;
this.isOpen = false;
this.initializationPromise = null;
this.availableLibraries = {};
this.activeLibrary = '';
// Add initialization to sync with modal state
this.currentPage = document.body.dataset.page || 'loras';
@@ -182,6 +185,14 @@ export class SettingsManager {
button.addEventListener('click', () => this.toggleInputVisibility(button));
});
const openSettingsLocationButton = document.querySelector('.settings-open-location-button');
if (openSettingsLocationButton) {
openSettingsLocationButton.addEventListener('click', () => {
const filePath = openSettingsLocationButton.dataset.settingsPath;
this.openSettingsFileLocation(filePath);
});
}
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (customInput) {
@@ -209,6 +220,32 @@ export class SettingsManager {
this.initialized = true;
}
async openSettingsFileLocation(filePath) {
if (!filePath) {
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
return;
}
try {
const response = await fetch('/api/lm/open-file-location', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ file_path: filePath }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
showToast('settings.openSettingsFileLocation.success', {}, 'success');
} catch (error) {
console.error('Failed to open settings file location:', error);
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
}
}
async loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
@@ -266,6 +303,9 @@ export class SettingsManager {
// Load base model path mappings
this.loadBaseModelMappings();
// Load library options
await this.loadLibraries();
// Load default lora root
await this.loadLoraRoots();
@@ -337,6 +377,151 @@ export class SettingsManager {
}
}
async loadLibraries() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const setPlaceholderOption = (textKey, fallback) => {
librarySelect.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = translate(textKey, {}, fallback);
librarySelect.appendChild(option);
};
setPlaceholderOption('settings.folderSettings.loadingLibraries', 'Loading libraries...');
librarySelect.disabled = true;
try {
const response = await fetch('/api/lm/settings/libraries');
if (!response.ok) {
throw new Error('Failed to fetch library registry');
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to fetch library registry');
}
const libraries = data.libraries && typeof data.libraries === 'object'
? data.libraries
: {};
this.availableLibraries = libraries;
const entries = Object.entries(libraries);
if (entries.length === 0) {
this.activeLibrary = '';
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
return;
}
const activeName = data.active_library && libraries[data.active_library]
? data.active_library
: entries[0][0];
this.activeLibrary = activeName;
librarySelect.innerHTML = '';
const fragment = document.createDocumentFragment();
entries
.sort((a, b) => {
const nameA = this.getLibraryDisplayName(a[0], a[1]).toLowerCase();
const nameB = this.getLibraryDisplayName(b[0], b[1]).toLowerCase();
return nameA.localeCompare(nameB);
})
.forEach(([name, info]) => {
const option = document.createElement('option');
option.value = name;
option.textContent = this.getLibraryDisplayName(name, info);
fragment.appendChild(option);
});
librarySelect.appendChild(fragment);
librarySelect.value = activeName;
librarySelect.disabled = entries.length <= 1;
} catch (error) {
console.error('Error loading libraries:', error);
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
this.availableLibraries = {};
this.activeLibrary = '';
librarySelect.disabled = true;
showToast('toast.settings.libraryLoadFailed', { message: error.message }, 'error');
}
}
getLibraryDisplayName(libraryName, libraryData = {}) {
if (libraryData && typeof libraryData === 'object') {
const metadata = libraryData.metadata;
if (metadata && typeof metadata === 'object' && metadata.display_name) {
return metadata.display_name;
}
if (libraryData.display_name) {
return libraryData.display_name;
}
}
return libraryName;
}
async handleLibraryChange() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const selectedLibrary = librarySelect.value;
if (!selectedLibrary || selectedLibrary === this.activeLibrary) {
librarySelect.value = this.activeLibrary;
return;
}
librarySelect.disabled = true;
try {
state.loadingManager.showSimpleLoading('Activating library...');
await this.activateLibrary(selectedLibrary);
// Add a short delay before reloading the page
await new Promise(resolve => setTimeout(resolve, 300));
window.location.reload();
} catch (error) {
console.error('Failed to activate library:', error);
showToast('toast.settings.libraryActivateFailed', { message: error.message }, 'error');
await this.loadLibraries();
} finally {
state.loadingManager.hide();
if (!document.hidden) {
librarySelect.disabled = librarySelect.options.length <= 1;
}
}
}
async activateLibrary(libraryName) {
const response = await fetch('/api/lm/settings/libraries/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ library: libraryName }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to activate library');
}
const activeName = data.active_library || libraryName;
this.activeLibrary = activeName;
return data;
}
async loadLoraRoots() {
try {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
@@ -1222,29 +1407,7 @@ export class SettingsManager {
// Apply autoplay setting to existing videos in card previews
const autoplayOnHover = state.global.settings.autoplay_on_hover;
document.querySelectorAll('.card-preview video').forEach(video => {
// Remove previous event listeners by cloning and replacing the element
const videoParent = video.parentElement;
const videoClone = video.cloneNode(true);
if (autoplayOnHover) {
// Pause video initially and set up mouse events for hover playback
videoClone.removeAttribute('autoplay');
videoClone.pause();
// Add mouse events to the parent element
videoParent.onmouseenter = () => videoClone.play();
videoParent.onmouseleave = () => {
videoClone.pause();
videoClone.currentTime = 0;
};
} else {
// Use default autoplay behavior
videoClone.setAttribute('autoplay', '');
videoParent.onmouseenter = null;
videoParent.onmouseleave = null;
}
videoParent.replaceChild(videoClone, video);
configureModelCardVideo(video, autoplayOnHover);
});
// Apply display density class to grid

View File

@@ -82,11 +82,15 @@ export class UpdateService {
}
}
async checkForUpdates() {
async checkForUpdates({ force = false } = {}) {
if (!force && !this.updateNotificationsEnabled) {
return;
}
// Check if we should perform an update check
const now = Date.now();
const forceCheck = this.lastCheckTime === 0;
const forceCheck = force || this.lastCheckTime === 0;
if (!forceCheck && now - this.lastCheckTime < this.updateCheckInterval) {
// If we already have update info, just update the UI
if (this.updateAvailable) {
@@ -94,7 +98,7 @@ export class UpdateService {
}
return;
}
try {
// Call backend API to check for updates with nightly flag
const response = await fetch(`/api/lm/check-updates?nightly=${this.nightlyMode}`);
@@ -435,8 +439,7 @@ export class UpdateService {
}
async manualCheckForUpdates() {
this.lastCheckTime = 0; // Reset last check time to force check
await this.checkForUpdates();
await this.checkForUpdates({ force: true });
// Ensure badge visibility is updated after manual check
this.updateBadgeVisibility();
}

View File

@@ -5,7 +5,7 @@ import { showToast } from './utils/uiHelpers.js';
// Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project
class StatisticsManager {
export class StatisticsManager {
constructor() {
this.charts = {};
this.data = {};

View File

@@ -27,6 +27,7 @@ export const BASE_MODELS = {
FLUX_1_KREA: "Flux.1 Krea",
FLUX_1_KONTEXT: "Flux.1 Kontext",
AURAFLOW: "AuraFlow",
CHROMA: "Chroma",
PIXART_A: "PixArt a",
PIXART_E: "PixArt E",
HUNYUAN_1: "Hunyuan 1",
@@ -186,7 +187,7 @@ export const BASE_MODEL_CATEGORIES = {
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA],
'Other Models': [
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW,
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.UNKNOWN

View File

@@ -14,6 +14,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
<link rel="manifest" href="/loras_static/images/site.webmanifest">
<link rel="preload" as="font" type="font/woff2" href="/loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2" crossorigin>
<!-- 添加性能监控 -->
<script>
performance.mark('page-start');

View File

@@ -56,6 +56,9 @@
<div class="context-menu-item" data-action="set-base-model">
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
</div>
<div class="context-menu-item" data-action="set-content-rating">
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
</div>
<div class="context-menu-item" data-action="send-to-workflow-append">
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
</div>

View File

@@ -6,33 +6,48 @@
<span class="app-title">{{ t('header.appTitle') }}</span>
</a>
</div>
{% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %}
{% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
{% set current_page = 'loras' %}
{% endif %}
{% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<nav class="main-nav">
<a href="/loras" class="nav-item" id="lorasNavItem">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item" id="recipesNavItem">
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}" id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}" id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item" id="embeddingsNavItem">
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}" id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/statistics" class="nav-item" id="statisticsNavItem">
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
<!-- Context-aware search container -->
<div class="header-search" id="headerSearch">
<div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t('header.search.placeholder') }}" />
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}"{% if search_disabled %} disabled{% endif %} />
<i class="fas fa-search search-icon"></i>
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}">
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
<i class="fas fa-sliders-h"></i>
</button>
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}">
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
<i class="fas fa-filter"></i>
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
</button>
@@ -131,29 +146,3 @@
</div>
</div>
<!-- Header JavaScript will be handled by the HeaderManager in Header.js -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Get the current path from the URL
const currentPath = window.location.pathname;
// Update active nav item (i18n is handled by the HeaderManager)
const lorasNavItem = document.getElementById('lorasNavItem');
const recipesNavItem = document.getElementById('recipesNavItem');
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
const embeddingsNavItem = document.getElementById('embeddingsNavItem');
const statisticsNavItem = document.getElementById('statisticsNavItem');
if (currentPath === '/loras') {
lorasNavItem.classList.add('active');
} else if (currentPath === '/loras/recipes') {
recipesNavItem.classList.add('active');
} else if (currentPath === '/checkpoints') {
checkpointsNavItem.classList.add('active');
} else if (currentPath === '/embeddings') {
embeddingsNavItem.classList.add('active');
} else if (currentPath === '/statistics') {
statisticsNavItem.classList.add('active');
}
});
</script>

View File

@@ -2,7 +2,17 @@
<div id="settingsModal" class="modal">
<div class="modal-content settings-modal">
<button class="close" onclick="modalManager.closeModal('settingsModal')">&times;</button>
<h2>{{ t('common.actions.settings') }}</h2>
<div class="settings-header">
<h2>{{ t('common.actions.settings') }}</h2>
<button
type="button"
class="settings-open-location-button"
data-settings-path="{{ settings.settings_file }}"
aria-label="{{ t('settings.openSettingsFileLocation.tooltip') }}"
title="{{ t('settings.openSettingsFileLocation.tooltip') }}">
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
</button>
</div>
<div class="settings-form">
<div class="setting-item api-key-item">
<div class="setting-row">
@@ -158,6 +168,7 @@
<option value="ko">{{ t('common.language.korean') }}</option>
<option value="fr">{{ t('common.language.french') }}</option>
<option value="es">{{ t('common.language.spanish') }}</option>
<option value="he">{{ t('common.language.Hebrew') }}</option>
</select>
</div>
</div>
@@ -170,7 +181,23 @@
<!-- Add Folder Settings Section -->
<div class="settings-section">
<h3>{{ t('settings.sections.folderSettings') }}</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="librarySelect">{{ t('settings.folderSettings.activeLibrary') }}</label>
</div>
<div class="setting-control select-control">
<select id="librarySelect" onchange="settingsManager.handleLibraryChange()">
<option value="">{{ t('settings.folderSettings.loadingLibraries') }}</option>
</select>
</div>
</div>
<div class="input-help">
{{ t('settings.folderSettings.activeLibraryHelp') }}
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -595,4 +622,4 @@
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
import logging
from typing import Dict, Iterable, List
import pytest
from py import config as config_module
from py.services import settings_manager as settings_manager_module
def _setup_config_environment(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Dict[str, List[str]]:
loras_dir = tmp_path / "loras"
checkpoints_dir = tmp_path / "checkpoints"
embeddings_dir = tmp_path / "embeddings"
for directory in (loras_dir, checkpoints_dir, embeddings_dir):
directory.mkdir()
folder_paths: Dict[str, List[str]] = {
"loras": [str(loras_dir)],
"checkpoints": [str(checkpoints_dir)],
"unet": [],
"embeddings": [str(embeddings_dir)],
}
def fake_get_folder_paths(kind: str) -> Iterable[str]:
return folder_paths.get(kind, [])
monkeypatch.setattr(config_module.folder_paths, "get_folder_paths", fake_get_folder_paths)
monkeypatch.setattr(config_module, "standalone_mode", False)
monkeypatch.setattr(
config_module,
"ensure_settings_file",
lambda logger=None: str(tmp_path / "settings.json"),
)
return folder_paths
def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def __init__(self, default_paths: Dict[str, List[str]]):
self._default_paths = default_paths
self.rename_calls = []
self.upsert_calls = []
self._renamed = False
def get_libraries(self):
if self._renamed:
return {"comfyui": {}}
return {
"default": {
"folder_paths": {key: list(value) for key, value in self._default_paths.items()},
"default_lora_root": "",
"default_checkpoint_root": "",
"default_embedding_root": "",
}
}
def rename_library(self, old_name: str, new_name: str):
self.rename_calls.append((old_name, new_name))
self._renamed = True
def upsert_library(self, name: str, **payload):
self.upsert_calls.append((name, payload))
fake_settings = FakeSettingsService(folder_paths)
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_instance = config_module.Config()
assert isinstance(config_instance, config_module.Config)
assert fake_settings.rename_calls == [("default", "comfyui")]
assert len(fake_settings.upsert_calls) == 1
name, payload = fake_settings.upsert_calls[0]
assert name == "comfyui"
# The Config class normalizes paths to use forward slashes for cross-platform compatibility
# Convert expected paths to the same format for comparison
expected_folder_paths = {
key: [path.replace("\\", "/") for path in paths]
for key, paths in folder_paths.items()
}
assert payload["folder_paths"] == expected_folder_paths
assert payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
assert payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
assert payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
assert payload["metadata"] == {"display_name": "ComfyUI", "source": "comfyui"}
assert payload["activate"] is True
def test_save_paths_logs_warning_when_upsert_fails(
monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class RaisingSettingsService:
def __init__(self):
self.upsert_attempts = []
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": "existing",
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def upsert_library(self, name: str, **payload):
self.upsert_attempts.append((name, payload))
raise RuntimeError("boom")
fake_settings = RaisingSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
with caplog.at_level(logging.WARNING, logger=config_module.logger.name):
config_instance = config_module.Config()
assert isinstance(config_instance, config_module.Config)
assert fake_settings.upsert_attempts and fake_settings.upsert_attempts[0][0] == "comfyui"
assert "Failed to save folder paths: boom" in caplog.text

View File

@@ -1,13 +1,45 @@
import asyncio
import importlib.util
import inspect
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
import asyncio
import inspect
from unittest import mock
import sys
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
PY_INIT = REPO_ROOT / "py" / "__init__.py"
def _load_repo_package(name: str) -> types.ModuleType:
"""Ensure the repository's ``py`` package is importable under *name*."""
module = sys.modules.get(name)
if module and getattr(module, "__file__", None) == str(PY_INIT):
return module
spec = importlib.util.spec_from_file_location(
name,
PY_INIT,
submodule_search_locations=[str(PY_INIT.parent)],
)
if spec is None or spec.loader is None: # pragma: no cover - initialization guard
raise ImportError(f"Unable to load repository package for alias '{name}'")
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package) # type: ignore[attr-defined]
package.__path__ = [str(PY_INIT.parent)] # type: ignore[attr-defined]
sys.modules[name] = package
return package
_repo_package = _load_repo_package("py")
sys.modules.setdefault("py_local", _repo_package)
# Mock ComfyUI modules before any imports from the main project
server_mock = types.SimpleNamespace()
server_mock.PromptServer = mock.MagicMock()

View File

@@ -0,0 +1,139 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
const previewTooltipMock = {
show: vi.fn(),
hide: vi.fn(),
cleanup: vi.fn(),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => previewTooltipMock),
}));
describe('AutoComplete widget interactions', () => {
beforeEach(() => {
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
previewTooltipMock.show.mockReset();
previewTooltipMock.hide.mockReset();
previewTooltipMock.cleanup.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('fetches and renders search results when input exceeds the minimum characters', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=20');
const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
expect(items).toHaveLength(1);
expect(autoComplete.dropdown.style.display).toBe('block');
expect(autoComplete.isVisible).toBe(true);
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
});
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
usage_tips: JSON.stringify({ strength: '1.5', clip_strength: '0.9' }),
}),
});
}
return Promise.resolve({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
});
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
const input = document.createElement('textarea');
input.value = 'alpha, example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
await autoComplete.insertSelection('models/example.safetensors');
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
);
expect(input.value).toContain('<lora:example:1.5:0.9>, ');
expect(autoComplete.dropdown.style.display).toBe('none');
expect(input.focus).toHaveBeenCalled();
expect(input.setSelectionRange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,319 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const copyToClipboardMock = vi.fn();
const getNSFWLevelNameMock = vi.fn((level) => {
if (level >= 16) return 'XXX';
if (level >= 8) return 'X';
if (level >= 4) return 'R';
if (level >= 2) return 'PG13';
if (level >= 1) return 'PG';
return 'Unknown';
});
const copyLoraSyntaxMock = vi.fn();
const sendLoraToWorkflowMock = vi.fn();
const buildLoraSyntaxMock = vi.fn((fileName) => `lora:${fileName}`);
const openExampleImagesFolderMock = vi.fn();
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
registerModal: vi.fn(),
getModal: vi.fn(() => ({ element: { style: { display: 'none' } }, isOpen: false })),
isAnyModalOpen: vi.fn(),
};
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: { updateSingleItem: vi.fn() },
};
const saveModelMetadataMock = vi.fn();
const downloadExampleImagesApiMock = vi.fn();
const replaceModelPreviewMock = vi.fn();
const refreshSingleModelMetadataMock = vi.fn();
const resetAndReloadMock = vi.fn();
const getModelApiClientMock = vi.fn(() => ({
saveModelMetadata: saveModelMetadataMock,
downloadExampleImages: downloadExampleImagesApiMock,
replaceModelPreview: replaceModelPreviewMock,
refreshSingleModelMetadata: refreshSingleModelMetadataMock,
}));
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: copyToClipboardMock,
getNSFWLevelName: getNSFWLevelNameMock,
copyLoraSyntax: copyLoraSyntaxMock,
sendLoraToWorkflow: sendLoraToWorkflowMock,
buildLoraSyntax: buildLoraSyntaxMock,
openExampleImagesFolder: openExampleImagesFolderMock,
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: modalManagerMock,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getSessionItem: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(),
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
updateRecipeMetadata: updateRecipeMetadataMock,
}));
async function flushAsyncTasks() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('Interaction-level regression coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
document.body.innerHTML = '';
stateStub.global.settings = {};
saveModelMetadataMock.mockResolvedValue(undefined);
downloadExampleImagesApiMock.mockResolvedValue(undefined);
updateRecipeMetadataMock.mockResolvedValue({ success: true });
global.modalManager = modalManagerMock;
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
delete window.exampleImagesManager;
delete global.fetch;
delete global.modalManager;
});
it('opens the NSFW selector from the LoRA context menu and persists the new rating', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item" data-action="set-nsfw"></div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector" style="display: none;">
<div class="nsfw-level-header">
<button class="close-nsfw-selector"></button>
</div>
<div class="nsfw-level-content">
<div class="current-level"><span id="currentNSFWLevel"></span></div>
<div class="nsfw-level-options">
<button class="nsfw-level-btn" data-level="1"></button>
<button class="nsfw-level-btn" data-level="4"></button>
</div>
</div>
</div>
`;
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
card.dataset.meta = JSON.stringify({ preview_nsfw_level: 1 });
document.body.appendChild(card);
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const helpers = await import('../../../static/js/utils/uiHelpers.js');
expect(helpers.showToast).toBe(showToastMock);
const contextMenu = new LoraContextMenu();
contextMenu.showMenu(120, 140, card);
const nsfwMenuItem = document.querySelector('#loraContextMenu .context-menu-item[data-action="set-nsfw"]');
nsfwMenuItem.dispatchEvent(new Event('click', { bubbles: true }));
const selector = document.getElementById('nsfwLevelSelector');
expect(selector.style.display).toBe('block');
expect(selector.dataset.cardPath).toBe('/models/test.safetensors');
expect(document.getElementById('currentNSFWLevel').textContent).toBe('PG');
const levelButton = selector.querySelector('.nsfw-level-btn[data-level="4"]');
levelButton.dispatchEvent(new Event('click', { bubbles: true }));
expect(saveModelMetadataMock).toHaveBeenCalledWith('/models/test.safetensors', { preview_nsfw_level: 4 });
expect(saveModelMetadataMock).toHaveBeenCalledTimes(1);
await saveModelMetadataMock.mock.results[0].value;
await flushAsyncTasks();
expect(selector.style.display).toBe('none');
expect(document.getElementById('loraContextMenu').style.display).toBe('none');
});
it('wires recipe modal title editing to update metadata and UI state', async () => {
document.body.innerHTML = `
<div id="recipeModal" class="modal">
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-tags-container">
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
</div>
</div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
<div class="param-header">
<label>Prompt</label>
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
</div>
<div class="param-content" id="recipePrompt"></div>
</div>
<div class="param-group info-item">
<div class="param-header">
<label>Negative Prompt</label>
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
</div>
<div class="param-content" id="recipeNegativePrompt"></div>
</div>
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
<div class="recipe-section-actions">
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
</div>
</div>
</div>
</div>
`;
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
const recipe = {
id: 'recipe-1',
file_path: '/recipes/test.json',
title: 'Original Title',
tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6'],
file_url: '',
preview_url: '',
source_path: '',
gen_params: {
prompt: 'Prompt text',
negative_prompt: 'Negative prompt',
steps: '30',
},
loras: [],
};
recipeModal.showRecipeDetails(recipe);
await new Promise((resolve) => setTimeout(resolve, 60));
await flushAsyncTasks();
expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal');
const editIcon = document.querySelector('#recipeModalTitle .edit-icon');
editIcon.dispatchEvent(new Event('click', { bubbles: true }));
const titleInput = document.querySelector('#recipeTitleEditor .title-input');
titleInput.value = 'Updated Title';
recipeModal.saveTitleEdit();
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/test.json', { title: 'Updated Title' });
expect(updateRecipeMetadataMock).toHaveBeenCalledTimes(1);
await updateRecipeMetadataMock.mock.results[0].value;
await flushAsyncTasks();
const titleContainer = document.getElementById('recipeModalTitle');
expect(titleContainer.querySelector('.content-text').textContent).toBe('Updated Title');
expect(titleContainer.querySelector('#recipeTitleEditor').classList.contains('active')).toBe(false);
expect(recipeModal.currentRecipe.title).toBe('Updated Title');
});
it('processes global context menu actions for downloads and cleanup', async () => {
document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu">
<div class="context-menu-item" data-action="download-example-images"></div>
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
</div>
`;
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
const menu = new GlobalContextMenu();
stateStub.global.settings.example_images_path = '/tmp/examples';
window.exampleImagesManager = {
handleDownloadButton: vi.fn().mockResolvedValue(undefined),
};
menu.showMenu(100, 200);
const downloadItem = document.querySelector('[data-action="download-example-images"]');
downloadItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadItem.classList.contains('disabled')).toBe(true);
expect(window.exampleImagesManager.handleDownloadButton).toHaveBeenCalledTimes(1);
await window.exampleImagesManager.handleDownloadButton.mock.results[0].value;
await flushAsyncTasks();
expect(downloadItem.classList.contains('disabled')).toBe(false);
expect(document.getElementById('globalContextMenu').style.display).toBe('none');
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, moved_total: 2 }),
});
menu.showMenu(240, 320);
const cleanupItem = document.querySelector('[data-action="cleanup-example-images-folders"]');
cleanupItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(cleanupItem.classList.contains('disabled')).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/cleanup-example-image-folders', { method: 'POST' });
expect(global.fetch).toHaveBeenCalledTimes(1);
const responsePromise = global.fetch.mock.results[0].value;
const response = await responsePromise;
await response.json();
await flushAsyncTasks();
expect(cleanupItem.classList.contains('disabled')).toBe(false);
expect(menu._cleanupInProgress).toBe(false);
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
EVENTS_MODULE,
API_MODULE,
APP_MODULE,
COMPONENTS_MODULE,
} = vi.hoisted(() => ({
EVENTS_MODULE: new URL('../../../web/comfyui/loras_widget_events.js', import.meta.url).pathname,
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
COMPONENTS_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname,
}));
vi.mock(API_MODULE, () => ({
api: {},
}));
vi.mock(APP_MODULE, () => ({
app: {},
}));
vi.mock(COMPONENTS_MODULE, () => ({
createMenuItem: vi.fn(),
createDropIndicator: vi.fn(),
}));
describe('LoRA widget drag interactions', () => {
beforeEach(() => {
document.body.innerHTML = '';
const dragStyle = document.getElementById('comfy-lora-drag-style');
if (dragStyle) {
dragStyle.remove();
}
});
afterEach(() => {
document.body.classList.remove('comfy-lora-dragging');
});
it('adjusts a single LoRA strength while syncing collapsed clip strength', async () => {
const { handleStrengthDrag } = await import(EVENTS_MODULE);
const widget = {
value: [
{ name: 'Test', strength: 0.5, clipStrength: 0.25, expanded: false },
],
callback: vi.fn(),
};
handleStrengthDrag('Test', 0.5, 100, { clientX: 140 }, widget, false);
expect(widget.value[0].strength).toBeCloseTo(0.54, 2);
expect(widget.value[0].clipStrength).toBeCloseTo(0.54, 2);
expect(widget.callback).toHaveBeenCalledWith(widget.value);
});
it('applies proportional drag updates to all LoRAs', async () => {
const { handleAllStrengthsDrag } = await import(EVENTS_MODULE);
const widget = {
value: [
{ name: 'A', strength: 0.4, clipStrength: 0.4 },
{ name: 'B', strength: 0.6, clipStrength: 0.6 },
],
callback: vi.fn(),
};
const initialStrengths = [
{ modelStrength: 0.4, clipStrength: 0.4 },
{ modelStrength: 0.6, clipStrength: 0.6 },
];
handleAllStrengthsDrag(initialStrengths, 100, { clientX: 160 }, widget);
expect(widget.value[0].strength).toBeCloseTo(0.41, 2);
expect(widget.value[1].strength).toBeCloseTo(0.62, 2);
expect(widget.callback).toHaveBeenCalledWith(widget.value);
});
it('initiates drag gestures, updates strength, and clears cursor state on mouseup', async () => {
const module = await import(EVENTS_MODULE);
const renderSpy = vi.fn();
const previewSpy = { hide: vi.fn() };
const dragEl = document.createElement('div');
dragEl.className = 'comfy-lora-entry';
document.body.append(dragEl);
const widget = {
value: [{ name: 'Test', strength: 0.5, clipStrength: 0.5 }],
callback: vi.fn(),
};
module.initDrag(dragEl, 'Test', widget, false, previewSpy, renderSpy);
dragEl.dispatchEvent(new MouseEvent('mousedown', { clientX: 50, bubbles: true }));
expect(document.body.classList.contains('comfy-lora-dragging')).toBe(true);
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 70, bubbles: true }));
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
expect(previewSpy.hide).toHaveBeenCalled();
expect(widget.value[0].strength).not.toBe(0.5);
document.dispatchEvent(new MouseEvent('mouseup'));
expect(document.body.classList.contains('comfy-lora-dragging')).toBe(false);
});
});

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