Compare commits

..

70 Commits

Author SHA1 Message Date
Jin Yi
104bd43dc1 [fix] TypeScript errors in manager/compatibility branch (#4625) 2025-07-31 16:42:56 +09:00
Jin Yi
e085bb4c0f [feat] Add import failure detection and error handling for package manager (#4600) 2025-07-31 16:42:56 +09:00
Jin Yi
afe0d68ae6 [refactor] Simplify conflict acknowledgment system and enhance UX (#4599) 2025-07-31 16:42:56 +09:00
Jin Yi
4c4625bb09 [refactor] Simplify conflict detection types and improve code maintainability (#4589) 2025-07-31 16:42:56 +09:00
Jin Yi
17dcc31585 [feat] Show conflicting status for installed packages with compatibility issues (#4579)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 16:42:56 +09:00
Jin Yi
2bff313135 [debug] Conflict detection debugging (#4577) 2025-07-31 16:42:56 +09:00
Jin Yi
9a4928974e [Design] Update InfoPanel styling and layout (#4553) 2025-07-31 16:42:56 +09:00
Jin Yi
25c6efca66 [feat] Add bulk import failure info API and improve conflict detection (#4550)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 16:42:56 +09:00
Jin Yi
e04117cf21 [Design] Update PackVersionSelectorPopover styling (#4554) 2025-07-31 16:42:56 +09:00
Jin Yi
964da0084d [fix] Simplify PackEnableToggle state management (#4551) 2025-07-31 16:42:56 +09:00
Jin Yi
9052496402 [Manager] Optimize conflict detection with bulk API and version support (#4538) 2025-07-31 16:42:56 +09:00
Jin Yi
b912416138 [Manager] Fix version selector conflict detection and UI improvements (#4539) 2025-07-31 16:42:56 +09:00
Jin Yi
c9ea9893ee [Manager] Fix banned status logic to only check Registry status (#4535) 2025-07-31 16:42:56 +09:00
Jin Yi
5edf856ce9 [Manager] Fix toggle modal dismiss bug (#4534) 2025-07-31 16:42:56 +09:00
Jin Yi
783f39873f chore: delete unnecessary description 2025-07-31 16:42:56 +09:00
comfy-jinyi
34adfc8bf1 [Manager] Fix package version matching in conflict detection (#4530)
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 16:42:56 +09:00
Jin Yi
af0dde0ac8 Manager Conflict Nofitication (#4443)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 16:42:56 +09:00
Jin Yi
4b6739c6fb [Manager] Compatibility Detection Logic (#4348)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-31 16:42:56 +09:00
github-actions
6ffba22663 Update locales [skip ci] 2025-07-31 16:42:19 +09:00
github-actions
d260ae0882 Update locales [skip ci] 2025-07-31 16:42:19 +09:00
github-actions
fe7c3cea4e Update locales [skip ci] 2025-07-31 16:42:19 +09:00
github-actions
278409e8fa Update locales [skip ci] 2025-07-31 16:42:19 +09:00
Jin Yi
237ef9c597 [test] Update PackVersionBadge test to use role selector instead of Button component 2025-07-31 16:42:19 +09:00
github-actions
9224ffbb37 Update locales [skip ci] 2025-07-31 16:42:19 +09:00
bymyself
3017ebba13 fix rebase error 2025-07-31 16:41:53 +09:00
Jin Yi
01c2341a11 [Manager] “Restarting” state after clicking restart button (#4269) 2025-07-31 16:41:53 +09:00
Jin Yi
3d0ae0f884 [Manager] Add update all button functionality
- Add PackUpdateButton component for bulk updates
- Add useUpdateAvailableNodes composable to track available updates
- Integrate update all button in RegistrySearchBar
- Add localization strings for update functionality
- Add comprehensive tests for update functionality
- Add loading state to PackActionButton
2025-07-31 16:41:53 +09:00
bymyself
c8137ab535 [tests] Update useServerLogs test after log subscription change
The test was expecting subscribeLogs(false) to be called, but this was commented out in commit 33d64475 to fix logs stopping after the first of multiple queue tasks. Updated test to reflect this temporary change.
2025-07-31 16:41:53 +09:00
bymyself
642f79c20d remove the temporary check for legacy custom node version of manager 2025-07-31 16:41:53 +09:00
bymyself
64a2d0d152 fix: logs stops listening after 1st of multiple queue tasks 2025-07-31 16:41:53 +09:00
bymyself
22d04213a4 [tests] Update useServerLogs test to handle task-started events
Update test to simulate cm-task-started events before logs events to match the actual behavior of the composable.
2025-07-31 16:41:53 +09:00
Christian Byrne
6a07ead4e4 [Manager] Fix: failed tasks logs not correctly partitioned in UI (#4242) 2025-07-31 16:41:53 +09:00
bymyself
d61c0483c4 fix failed task tab state binding 2025-07-31 16:41:53 +09:00
Christian Byrne
175b728dd9 [Manager] Filter task queue and history by client id (#4241) 2025-07-31 16:41:53 +09:00
github-actions
8ad3a0b212 Update locales [skip ci] 2025-07-31 16:41:53 +09:00
bymyself
d8799b6a9f fix rebase errors 2025-07-31 16:41:09 +09:00
bymyself
0177361554 [manager] Fix test failures and missing type definitions
- Fix ManagerProgressDialogContent test mock to include all required store methods
- Add missing MergedNodePack, RegistryPack type definitions and isMergedNodePack type guard
- Ensure all unit tests (548 passed) and component tests (174 passed) are working
- Fix TypeScript compilation errors related to manager store interfaces
2025-07-31 16:41:09 +09:00
bymyself
0ec9702ead [manager] Update tests for new manager API
Updated tests for manager queue composable, server logs composable, and manager store to work with the new API signatures and functionality.
2025-07-31 16:41:09 +09:00
bymyself
eef8c0408a [manager] Update UI components for new manager interface
Updated manager dialog components, pack cards, version selectors, and action buttons to work with the new manager API and state management structure.
2025-07-31 16:41:09 +09:00
bymyself
42a7deae2b [manager] Update composables and state management
Updated manager queue composable, server logs composable, workflow packs composable, and manager store to support the new manager API structure and state management patterns.
2025-07-31 16:41:09 +09:00
bymyself
3db88b7172 [manager] Update core services for new manager API
Updated ComfyUI Manager service and dialog service to support the new menu items structure and API endpoints.
2025-07-31 16:41:09 +09:00
bymyself
f2cfd2a841 [manager] Update type definitions and schemas for menu items migration
Updated ComfyUI Manager types and API schemas to support the new menu items structure and manager functionality.
2025-07-31 16:41:09 +09:00
github-actions
dafa767c0c Update locales [skip ci] 2025-07-31 16:41:09 +09:00
bymyself
f10b693bbb dont show missing nodes button in legacy manager mode 2025-07-31 16:40:53 +09:00
bymyself
efde93168e use correct response shape 2025-07-31 16:40:53 +09:00
github-actions
a50c26fe76 Update locales [skip ci] 2025-07-31 16:40:53 +09:00
bymyself
6a87933a78 add "Check for Updates", "Install Missing" menu items 2025-07-31 16:39:00 +09:00
bymyself
f82d8d56b8 move legacy option to startup arg 2025-07-31 16:38:41 +09:00
bymyself
891593971b await promises. update settings schema 2025-07-31 16:38:41 +09:00
github-actions
408644fe08 Update locales [skip ci] 2025-07-31 16:38:41 +09:00
bymyself
38ab2833eb migrate manager menu items 2025-07-31 16:34:26 +09:00
github-actions
5abe2d0822 Update locales [skip ci] 2025-07-30 18:30:36 +00:00
Christian Byrne
bf9cf06de2 Fix errors from rebase (removed Tag component import and duplicated imports in api.ts) (#4608)
Co-authored-by: github-actions <github-actions@github.com>
2025-07-30 11:26:05 -07:00
Christian Byrne
c7520eac3e [fix] Fix json syntax error from rebase (#4607) 2025-07-30 10:22:05 -07:00
Jin Yi
972160782c Merge branch 'main' into manager/menu-items-migration 2025-07-30 19:56:49 +09:00
github-actions
df67cdd86c Update locales [skip ci] 2025-07-21 17:41:45 +09:00
bymyself
478499c188 [Update to v2 API] update WS done message 2025-07-21 17:41:45 +09:00
bymyself
a5efa8580e dont show missing nodes button in legacy manager mode 2025-07-21 17:41:44 +09:00
bymyself
ab2e70b4b8 improve command names 2025-07-21 17:41:44 +09:00
bymyself
27579400bf use correct response shape 2025-07-21 17:41:44 +09:00
github-actions
b9341ad144 Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
9c68491129 add "Check for Updates", "Install Missing" menu items 2025-07-21 17:41:44 +09:00
github-actions
4f5ec0447c Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
0d1e5cb02a Add banner indicating how to use legacy manager UI 2025-07-21 17:41:44 +09:00
bymyself
797a612227 move legacy option to startup arg 2025-07-21 17:41:44 +09:00
bymyself
0a0a2e74d5 await promises. update settings schema 2025-07-21 17:41:44 +09:00
bymyself
6824e48efe re-arrange menu items 2025-07-21 17:41:44 +09:00
bymyself
257f618ee1 switch to v2 manager API endpoints 2025-07-21 17:41:44 +09:00
github-actions
a446483d7e Update locales [skip ci] 2025-07-21 17:41:44 +09:00
bymyself
a3d7c59b6f migrate manager menu items 2025-07-21 17:41:44 +09:00
150 changed files with 11005 additions and 5846 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

196
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.3",
"version": "1.25.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.3",
"version": "1.25.2",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -445,6 +445,7 @@
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.5.0.tgz",
"integrity": "sha512-dKnk2xlAyC7rvTkpkHmu+Qy/2Zc3Vm/l8PtNyIOGDBtXPY3kThfU4ORNEp3V7SXw5XSOb+tOJaUYpfquPzL/Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
"package-manager-detector": "^0.2.5",
"tinyexec": "^0.3.1"
@@ -566,6 +567,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@@ -619,6 +621,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@@ -649,6 +652,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@@ -908,9 +912,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
"integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2333,6 +2337,7 @@
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz",
"integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.0.0",
"@antfu/utils": "^8.1.0",
@@ -2349,6 +2354,7 @@
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
@@ -2362,6 +2368,7 @@
"resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz",
"integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
@@ -2370,13 +2377,15 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/utils/node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -2394,6 +2403,7 @@
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz",
"integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.0.1",
@@ -2410,19 +2420,22 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz",
"integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/utils/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@iconify/utils/node_modules/pkg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz",
"integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
"exsolve": "^1.0.7",
@@ -2433,7 +2446,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@inkjs/ui": {
"version": "1.0.0",
@@ -2542,6 +2556,18 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/synckit": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz",
@@ -4635,7 +4661,8 @@
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz",
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@types/stats.js": {
"version": "0.17.3",
@@ -6089,15 +6116,15 @@
}
},
"node_modules/broker-factory": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.7.tgz",
"integrity": "sha512-RxbMXWq/Qvw9aLZMvuooMtVTm2/SV9JEpxpBbMuFhYAnDaZxctbJ+1b9ucHxADk/eQNqDijvWQjLVARqExAeyg==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.8.tgz",
"integrity": "sha512-xmVnYN0FZtynhPUmAnN+/MFRdbDi3syCuxWV7o7s78FcIN0pjDtn9mUrVqEgdjQkbfojRhlPWbYbXJkMCyddrg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"fast-unique-numbers": "^9.0.22",
"tslib": "^2.8.1",
"worker-factory": "^7.0.43"
"worker-factory": "^7.0.44"
}
},
"node_modules/browserslist": {
@@ -6667,7 +6694,8 @@
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
@@ -7841,6 +7869,18 @@
"eslint": ">=6.0.0"
}
},
"node_modules/eslint-compat-utils/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz",
@@ -8389,7 +8429,8 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
@@ -8412,53 +8453,53 @@
}
},
"node_modules/extendable-media-recorder": {
"version": "9.2.27",
"resolved": "https://registry.npmjs.org/extendable-media-recorder/-/extendable-media-recorder-9.2.27.tgz",
"integrity": "sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==",
"version": "9.2.28",
"resolved": "https://registry.npmjs.org/extendable-media-recorder/-/extendable-media-recorder-9.2.28.tgz",
"integrity": "sha512-OIltlqo8rIUOcPn5c6aZ0qCvcTpXDnBgts8eRXK1VQKCI1omYBgZZyzkJ5vrQnB8xR34+GNOFs1Z+srq6tQsdQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"media-encoder-host": "^9.0.20",
"media-encoder-host": "^9.0.21",
"multi-buffer-data-view": "^6.0.22",
"recorder-audio-worklet": "^6.0.48",
"recorder-audio-worklet": "^6.0.49",
"standardized-audio-context": "^25.3.77",
"subscribable-things": "^2.1.53",
"tslib": "^2.8.1"
}
},
"node_modules/extendable-media-recorder-wav-encoder": {
"version": "7.0.129",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.129.tgz",
"integrity": "sha512-/wqM2hnzvLy/iUlg/EU3JIF8MJcidy8I77Z7CCm5+CVEClDfcs6bH9PgghuisndwKTaud0Dh48RTD83gkfEjCw==",
"version": "7.0.130",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.130.tgz",
"integrity": "sha512-tVroIOesnMarsm+iIRiWUEYgmQj/lGqeMVNwJla7/tTVkX3ZPamh0NW59deILANwDIJq9lARLQmsUhyDWW57PA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"extendable-media-recorder-wav-encoder-broker": "^7.0.119",
"extendable-media-recorder-wav-encoder-worker": "^8.0.116",
"extendable-media-recorder-wav-encoder-broker": "^7.0.120",
"extendable-media-recorder-wav-encoder-worker": "^8.0.117",
"tslib": "^2.8.1"
}
},
"node_modules/extendable-media-recorder-wav-encoder-broker": {
"version": "7.0.119",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.119.tgz",
"integrity": "sha512-BLrFOnqFLpsmmNpSk/TfjNs4j6ImCSGtoryIpRlqNu5S/Avt6gRJI0s4UYvdK7h17PCi+8vaDr75blvmU1sYlw==",
"version": "7.0.120",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.120.tgz",
"integrity": "sha512-wYRnDsHngGGR7LnEfl+tkNa7ck31KSV3PzqGLMCeKRmr02OrYxJUKyCeYf79TEe8O0EFtlBGBR9fiAj6OGDOhg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"broker-factory": "^3.1.7",
"extendable-media-recorder-wav-encoder-worker": "^8.0.116",
"broker-factory": "^3.1.8",
"extendable-media-recorder-wav-encoder-worker": "^8.0.117",
"tslib": "^2.8.1"
}
},
"node_modules/extendable-media-recorder-wav-encoder-worker": {
"version": "8.0.116",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.116.tgz",
"integrity": "sha512-bJPR0B7ZHeoqi9YoSie+UXAfEYya3efQ9eLiWuyK4KcOv+SuYQvWCoyzX5kjvb6GqIBCUnev5xulfeHRlyCwvw==",
"version": "8.0.117",
"resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.117.tgz",
"integrity": "sha512-rnlIPkMB5F2sslesLXLdJ/Z0Kes4ROtdr+Kf/a6aF3233oJWK7165krWNZP5gpKZ4Z5Lhjx4fxVB12exZvlb2g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"tslib": "^2.8.1",
"worker-factory": "^7.0.43"
"worker-factory": "^7.0.44"
}
},
"node_modules/fast-deep-equal": {
@@ -9130,6 +9171,7 @@
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
@@ -10427,6 +10469,18 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/jsonc-eslint-parser/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsondiffpatch": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz",
@@ -10985,6 +11039,7 @@
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
"integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.3",
"pkg-types": "^1.2.1"
@@ -11527,40 +11582,40 @@
"license": "MIT"
},
"node_modules/media-encoder-host": {
"version": "9.0.20",
"resolved": "https://registry.npmjs.org/media-encoder-host/-/media-encoder-host-9.0.20.tgz",
"integrity": "sha512-IyEYxw6az97RNuETOAZV4YZqNAPOiF9GKIp5mVZb4HOyWd6mhkWQ34ydOzhqAWogMyc4W05kjN/VCgTtgyFmsw==",
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/media-encoder-host/-/media-encoder-host-9.0.21.tgz",
"integrity": "sha512-hL41jBYmTKxGB2Z82rcR2hx4JCFBR3fmSOLR+G7EEkZn+uwSXDJTRFiYd7XWKnpjiHXy7ewf0ByEpby+2mv4Qg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"media-encoder-host-broker": "^8.0.19",
"media-encoder-host-worker": "^10.0.19",
"media-encoder-host-broker": "^8.0.20",
"media-encoder-host-worker": "^10.0.20",
"tslib": "^2.8.1"
}
},
"node_modules/media-encoder-host-broker": {
"version": "8.0.19",
"resolved": "https://registry.npmjs.org/media-encoder-host-broker/-/media-encoder-host-broker-8.0.19.tgz",
"integrity": "sha512-lTpsNuaZdTCdtTHsOyww7Ae0Mwv+7mFS+O4YkFYWhXwVs0rm6XbRK5jRRn5JmcX3n1eTE1lQS5RgX8qbNaIjSg==",
"version": "8.0.20",
"resolved": "https://registry.npmjs.org/media-encoder-host-broker/-/media-encoder-host-broker-8.0.20.tgz",
"integrity": "sha512-Jab7frQx8d7Js+yBeMx7FeTrxRDjIpSmdARRkHSOtWNKJv9a+Hy2/OAPXoEJl6Y15jk1eiDwJkMMF6zvlnNMEA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"broker-factory": "^3.1.7",
"broker-factory": "^3.1.8",
"fast-unique-numbers": "^9.0.22",
"media-encoder-host-worker": "^10.0.19",
"media-encoder-host-worker": "^10.0.20",
"tslib": "^2.8.1"
}
},
"node_modules/media-encoder-host-worker": {
"version": "10.0.19",
"resolved": "https://registry.npmjs.org/media-encoder-host-worker/-/media-encoder-host-worker-10.0.19.tgz",
"integrity": "sha512-I8fwc6f41peER3RFSiwDxnIHbqU7p3pc2ghQozcw9CQfL0mWEo4IjQJtyswrrlL/HO2pgVSMQbaNzE4q/0mfDQ==",
"version": "10.0.20",
"resolved": "https://registry.npmjs.org/media-encoder-host-worker/-/media-encoder-host-worker-10.0.20.tgz",
"integrity": "sha512-Ysqjwlcu4VP6FlM+atQJZFFJwvfrw603gnmhWfyBlcE5+pl7ctc0wo2dbPiBpz9pc1pY4N5N6PHHhywkqHxfnA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"extendable-media-recorder-wav-encoder-broker": "^7.0.119",
"extendable-media-recorder-wav-encoder-broker": "^7.0.120",
"tslib": "^2.8.1",
"worker-factory": "^7.0.43"
"worker-factory": "^7.0.44"
}
},
"node_modules/media-typer": {
@@ -12298,6 +12353,7 @@
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"pathe": "^2.0.1",
@@ -12309,7 +12365,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
@@ -12867,6 +12924,7 @@
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz",
"integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"quansync": "^0.2.7"
}
@@ -13167,6 +13225,7 @@
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
@@ -13177,7 +13236,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.52.0",
@@ -13781,7 +13841,8 @@
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
]
],
"license": "MIT"
},
"node_modules/querystringify": {
"version": "2.2.0",
@@ -13941,19 +14002,19 @@
}
},
"node_modules/recorder-audio-worklet": {
"version": "6.0.48",
"resolved": "https://registry.npmjs.org/recorder-audio-worklet/-/recorder-audio-worklet-6.0.48.tgz",
"integrity": "sha512-PVlq/1hjCrPcUGqARg8rR30A303xDCao0jmlBTaUaKkN3Xme58RI7EQxurv8rw2eDwVrN+nrni0UoJoa5/v+zg==",
"version": "6.0.49",
"resolved": "https://registry.npmjs.org/recorder-audio-worklet/-/recorder-audio-worklet-6.0.49.tgz",
"integrity": "sha512-JLKSKh5rAWxReHqca/f3h9L8pzBF1KnB+N2oIP2jkTD37n2yupkCazKD2f20pUOGkaX6AJG998gtKNCHYJn4nw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"broker-factory": "^3.1.7",
"broker-factory": "^3.1.8",
"fast-unique-numbers": "^9.0.22",
"recorder-audio-worklet-processor": "^5.0.35",
"standardized-audio-context": "^25.3.77",
"subscribable-things": "^2.1.53",
"tslib": "^2.8.1",
"worker-factory": "^7.0.43"
"worker-factory": "^7.0.44"
}
},
"node_modules/recorder-audio-worklet-processor": {
@@ -14559,6 +14620,7 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -15333,7 +15395,8 @@
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.0.1",
@@ -16269,6 +16332,7 @@
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.1",
"picomatch": "^4.0.2",
@@ -16283,6 +16347,7 @@
"resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-0.22.0.tgz",
"integrity": "sha512-CP+iZq5U7doOifer5bcM0jQ9t3Is7EGybIYt3myVxceI8Zuk8EZEpe1NPtJvh7iqMs1VdbK0L41t9+um9VuuLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^0.5.0",
"@antfu/utils": "^0.7.10",
@@ -16329,6 +16394,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -16346,6 +16412,7 @@
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.28.0.tgz",
"integrity": "sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@antfu/utils": "^0.7.10",
"@rollup/pluginutils": "^5.1.4",
@@ -16392,6 +16459,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
@@ -16424,6 +16492,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -17435,7 +17504,8 @@
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/websocket-driver": {
"version": "0.7.4",
@@ -17615,9 +17685,9 @@
}
},
"node_modules/worker-factory": {
"version": "7.0.43",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.43.tgz",
"integrity": "sha512-SACVoj3gWKtMVyT9N+VD11Pd/Xe58+ZFfp8b7y/PagOvj3i8lU3Uyj+Lj7WYTmSBvNLC0JFaQkx44E6DhH5+WA==",
"version": "7.0.44",
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.44.tgz",
"integrity": "sha512-08AuUfWi+KeZI+KC7nU4pU/9tDeAFvE5NSWk+K9nIfuQc6UlOsZtjjeGVYVEn+DEchyXNJ5i10HCn0xRzFXEQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.3",
"version": "1.25.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -15,12 +15,14 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -47,5 +49,9 @@ onMounted(() => {
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div
class="inline-flex items-center justify-center"
:style="{ width: size + 'px', height: size + 'px' }"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 14 14"
fill="none"
class="animate-spin"
:style="{ animationDuration: duration }"
>
<g clip-path="url(#clip0_776_9582)">
<!-- Top dot -->
<path
class="dot-animation"
style="animation-delay: 0s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M7 2.21053C7.61042 2.21053 8.10526 1.71568 8.10526 1.10526C8.10526 0.494843 7.61042 0 7 0C6.38958 0 5.89474 0.494843 5.89474 1.10526C5.89474 1.71568 6.38958 2.21053 7 2.21053Z"
:fill="color"
/>
<!-- Left dot -->
<path
class="dot-animation"
style="animation-delay: 0.25s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.21053 7C2.21053 7.61042 1.71568 8.10526 1.10526 8.10526C0.494843 8.10526 0 7.61042 0 7C0 6.38958 0.494843 5.89474 1.10526 5.89474C1.71568 5.89474 2.21053 6.38958 2.21053 7Z"
:fill="color"
/>
<!-- Right dot -->
<path
class="dot-animation"
style="animation-delay: 0.5s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 7C14 7.61042 13.5052 8.10526 12.8947 8.10526C12.2843 8.10526 11.7895 7.61042 11.7895 7C11.7895 6.38958 12.2843 5.89474 12.8947 5.89474C13.5052 5.89474 14 6.38958 14 7Z"
:fill="color"
/>
<!-- Bottom dot -->
<path
class="dot-animation"
style="animation-delay: 0.75s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.10526 12.8947C8.10526 13.5052 7.61041 14 6.99999 14C6.38957 14 5.89473 13.5052 5.89473 12.8947C5.89473 12.2843 6.38957 11.7895 6.99999 11.7895C7.61041 11.7895 8.10526 12.2843 8.10526 12.8947Z"
:fill="color"
/>
<!-- Top-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.125s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 3.61349C2.48203 4.04513 3.18184 4.04513 3.61347 3.61349C4.0451 3.18186 4.0451 2.48205 3.61347 2.05042C3.18184 1.61878 2.48203 1.61878 2.05039 2.05042C1.61876 2.48205 1.61876 3.18186 2.05039 3.61349Z"
:fill="color"
/>
<!-- Bottom-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.625s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 11.9496C11.518 12.3812 10.8182 12.3812 10.3865 11.9496C9.9549 11.5179 9.9549 10.8181 10.3865 10.3865C10.8182 9.95485 11.518 9.95485 11.9496 10.3865C12.3812 10.8181 12.3812 11.5179 11.9496 11.9496Z"
:fill="color"
/>
<!-- Bottom-left dot -->
<path
class="dot-animation"
style="animation-delay: 0.875s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.05039 11.9496C2.48203 12.3812 3.18184 12.3812 3.61347 11.9496C4.0451 11.5179 4.0451 10.8181 3.61347 10.3865C3.18184 9.95485 2.48203 9.95485 2.05039 10.3865C1.61876 10.8181 1.61876 11.5179 2.05039 11.9496Z"
:fill="color"
/>
<!-- Top-right dot -->
<path
class="dot-animation"
style="animation-delay: 0.375s"
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9496 3.61349C11.518 4.04513 10.8182 4.04513 10.3865 3.61349C9.9549 3.18186 9.9549 2.48205 10.3865 2.05042C10.8182 1.61878 11.518 1.61878 11.9496 2.05042C12.3812 2.48205 12.3812 3.18186 11.9496 3.61349Z"
:fill="color"
/>
</g>
<defs>
<clipPath id="clip0_776_9582">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const { size = 24, duration = '2s' } = defineProps<{
size?: number
duration?: string
}>()
const colorPaletteStore = useColorPaletteStore()
const color = computed(() =>
colorPaletteStore.completedActivePalette.light_theme ? '#2C2B30' : '#D4D4D4'
)
</script>
<style scoped>
.dot-animation {
animation: dot-pulse 1s ease-in-out infinite;
}
@keyframes dot-pulse {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>

View File

@@ -1,124 +0,0 @@
<template>
<div
ref="containerRef"
class="relative overflow-hidden w-full h-full flex items-center justify-center"
>
<Skeleton
v-if="!isImageLoaded"
width="100%"
height="100%"
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
const {
src,
alt = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
// Use intersection observer to detect when the image container comes into view
useIntersectionObserver(
containerRef,
(entries) => {
const entry = entries[0]
isIntersecting.value = entry?.isIntersecting ?? false
},
{
rootMargin,
threshold: 0.1
}
)
// Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
hasError.value = true
} else if (cachedMedia.objectUrl) {
const acquiredUrl = acquireUrl(src)
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
} else {
cachedSrc.value = src
}
} catch (error) {
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
// Hide image when out of view
isImageLoaded.value = false
cachedSrc.value = undefined
hasError.value = false
}
},
{ immediate: true }
)
const onImageLoad = () => {
isImageLoaded.value = true
hasError.value = false
}
const onImageError = () => {
hasError.value = true
isImageLoaded.value = false
}
onUnmounted(() => {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
})
</script>

View File

@@ -27,7 +27,7 @@
/>
<template v-if="item.footerComponent" #footer>
<component :is="item.footerComponent" />
<component :is="item.footerComponent" v-bind="item.footerProps" />
</template>
</Dialog>
</template>

View File

@@ -31,7 +31,7 @@
</div>
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<div v-if="!isLegacyManager" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
@@ -45,14 +45,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -60,22 +60,11 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const isLegacyManager = ref(false)
const uniqueNodes = computed(() => {
const seenTypes = new Set()
@@ -103,6 +92,13 @@ const openManager = () => {
initialTab: ManagerTab.Missing
})
}
onMounted(async () => {
const isLegacyResponse = await useComfyManagerService().isLegacyManagerUI()
if (isLegacyResponse?.is_legacy_manager_ui) {
isLegacyManager.value = true
}
})
</script>
<style scoped>

View File

@@ -30,11 +30,20 @@ const defaultMockTaskLogs = [
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))

View File

@@ -18,7 +18,7 @@
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] || false"
toggleable
@@ -27,7 +27,7 @@
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
@@ -52,24 +52,24 @@
</template>
<div
:ref="
index === taskPanels.length - 1
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
'h-64': index !== focusedLogs.length - 1,
'flex-grow': index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
<pre class="whitespace-pre-wrap break-words">{{ logLine }}</pre>
</div>
</div>
</div>
@@ -90,17 +90,23 @@ import {
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
index === comfyManagerStore.managerQueue.historyCount - 1 &&
comfyManagerStore.isLoading
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
@@ -115,7 +121,7 @@ const { y: scrollY } = useScroll(sectionsContainerRef, {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false

View File

@@ -26,6 +26,35 @@
}"
>
<div class="px-6 flex flex-col h-full">
<!-- Conflict Warning Banner -->
<div
v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
>
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1">
<p class="text-sm font-bold m-0">
{{ $t('manager.conflicts.warningBanner.title') }}
</p>
<p class="text-xs m-0">
{{ $t('manager.conflicts.warningBanner.message') }}
</p>
<p
class="text-sm font-bold m-0 cursor-pointer"
@click="onClickWarningLink"
>
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<button
type="button"
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors"
:aria-label="$t('g.close')"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-sm"></i>
</button>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
@@ -34,6 +63,7 @@
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
:is-update-available-tab="isUpdateAvailableTab"
/>
<div class="flex-1 overflow-auto">
<div
@@ -69,7 +99,9 @@
:is-selected="
selectedNodePacks.some((pack) => pack.id === item.id)
"
@click.stop="(event) => selectNodePack(item, event)"
@click.stop="
(event: MouseEvent) => selectNodePack(item, event)
"
/>
</template>
</VirtualGrid>
@@ -101,7 +133,8 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -119,6 +152,7 @@ import { useManagerStatePersistence } from '@/composables/manager/useManagerStat
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -133,6 +167,7 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const conflictAcknowledgment = useConflictAcknowledgment()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -149,6 +184,13 @@ const {
toggle: toggleSideNav
} = useResponsiveCollapse()
// Use conflict acknowledgment state from composable
const {
shouldShowManagerBanner,
dismissWarningBanner,
dismissRedDotNotification
} = conflictAcknowledgment
const tabs = ref<TabItem[]>([
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
@@ -312,6 +354,13 @@ watch([isAllTab, searchResults], () => {
displayPacks.value = searchResults.value
})
const onClickWarningLink = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
@@ -440,6 +489,7 @@ whenever(selectedNodePack, async () => {
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
@@ -472,6 +522,10 @@ watch([searchQuery, selectedTab], () => {
}
})
watchEffect(() => {
dismissRedDotNotification()
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,

View File

@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tag from 'primevue/tag'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerHeader from './ManagerHeader.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
describe('ManagerHeader', () => {
const createWrapper = () => {
return mount(ManagerHeader, {
global: {
plugins: [createPinia(), PrimeVue, i18n],
directives: {
tooltip: Tooltip
},
components: {
Tag
}
}
})
}
it('renders the component title', () => {
const wrapper = createWrapper()
expect(wrapper.find('h2').text()).toBe(
enMessages.manager.discoverCommunityContent
)
})
it('displays the legacy manager UI tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
expect(tag.text()).toContain(enMessages.manager.legacyManagerUI)
})
it('applies info severity to the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('p-tag-info')
})
it('displays info icon in the tag', () => {
const wrapper = createWrapper()
const icon = wrapper.find('.pi-info-circle')
expect(icon.exists()).toBe(true)
})
it('has cursor-help class on the tag', () => {
const wrapper = createWrapper()
const tag = wrapper.find('[data-pc-name="tag"]')
expect(tag.classes()).toContain('cursor-help')
})
it('has proper structure with flex container', () => {
const wrapper = createWrapper()
const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4')
expect(flexContainer.exists()).toBe(true)
const tag = flexContainer.find('[data-pc-name="tag"]')
expect(tag.exists()).toBe(true)
})
})

View File

@@ -4,6 +4,22 @@
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
<div class="flex justify-end ml-auto pr-4">
<Tag
v-tooltip.left="$t('manager.legacyManagerUIDescription')"
severity="info"
icon="pi pi-info-circle"
:value="$t('manager.legacyManagerUI')"
class="cursor-help"
:pt="{
root: { class: 'text-xs' }
}"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
</script>

View File

@@ -0,0 +1,231 @@
<template>
<div class="w-[552px] flex flex-col">
<ContentDivider :width="1" />
<div class="px-4 py-6 w-full h-full flex flex-col gap-2">
<!-- Description -->
<div v-if="showAfterWhatsNew">
<p
class="text-sm leading-4 text-neutral-800 dark-theme:text-white m-0 mb-4"
>
{{ $t('manager.conflicts.description') }}
<br /><br />
{{ $t('manager.conflicts.info') }}
</p>
</div>
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleImportFailedPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
</div>
<div>
<Button
:icon="
importFailedExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Import failed list -->
<div
v-if="importFailedExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(packageName, i) in importFailedConflicts"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
</div>
<div>
<Button
:icon="
conflictsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Conflicts list -->
<div
v-if="conflictsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="(conflict, i) in allConflictDetails"
:key="i"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="w-full flex flex-col bg-neutral-200 dark-theme:bg-black min-h-8 rounded-lg"
>
<div
class="w-full h-8 flex items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex-1 flex">
<span
class="text-xs font-bold text-yellow-600 dark-theme:text-yellow-400 mr-2"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
</div>
<div>
<Button
:icon="
extensionsExpanded
? 'pi pi-chevron-down text-xs'
: 'pi pi-chevron-right text-xs'
"
text
class="text-neutral-600 dark-theme:text-neutral-300 !bg-transparent"
/>
</div>
</div>
<!-- Extension list -->
<div
v-if="extensionsExpanded"
class="py-2 px-4 flex flex-col gap-2.5 max-h-[142px] overflow-y-auto scrollbar-hide"
>
<div
v-for="conflictResult in conflictData"
:key="conflictResult.package_id"
class="flex items-center justify-between h-6 px-4 flex-shrink-0 conflict-list-item"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
</div>
<ContentDivider :width="1" />
</div>
</template>
<script setup lang="ts">
import { filter, flatMap, map, some } from 'lodash'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
interface Props {
showAfterWhatsNew?: boolean
}
const { showAfterWhatsNew } = withDefaults(defineProps<Props>(), {
showAfterWhatsNew: false
})
const { t } = useI18n()
const { conflictedPackages } = useConflictDetection()
const conflictsExpanded = ref<boolean>(false)
const extensionsExpanded = ref<boolean>(false)
const importFailedExpanded = ref<boolean>(false)
const conflictData = computed(() => conflictedPackages.value)
const allConflictDetails = computed(() => {
const allConflicts = flatMap(conflictData.value, (result) => result.conflicts)
return filter(allConflicts, (conflict) => conflict.type !== 'import_failed')
})
const packagesWithImportFailed = computed(() => {
return filter(conflictData.value, (result) =>
some(result.conflicts, (conflict) => conflict.type === 'import_failed')
)
})
const importFailedConflicts = computed(() => {
return map(
packagesWithImportFailed.value,
(result) => result.package_name || result.package_id
)
})
const toggleImportFailedPanel = () => {
importFailedExpanded.value = !importFailedExpanded.value
conflictsExpanded.value = false
extensionsExpanded.value = false
}
const toggleConflictsPanel = () => {
conflictsExpanded.value = !conflictsExpanded.value
extensionsExpanded.value = false
importFailedExpanded.value = false
}
const toggleExtensionsPanel = () => {
extensionsExpanded.value = !extensionsExpanded.value
conflictsExpanded.value = false
importFailedExpanded.value = false
}
</script>
<style scoped>
.conflict-list-item:hover {
background-color: rgba(0, 122, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="flex items-center justify-between w-full px-3 py-4">
<div class="w-full flex items-center justify-between gap-2 pr-1">
<Button
:label="$t('manager.conflicts.conflictInfoTitle')"
text
severity="secondary"
size="small"
icon="pi pi-info-circle"
:pt="{
label: { class: 'text-sm' }
}"
@click="handleConflictInfoClick"
/>
<Button
v-if="props.buttonText"
:label="props.buttonText"
severity="secondary"
size="small"
@click="handleButtonClick"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
interface Props {
buttonText?: string
onButtonClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
buttonText: undefined,
onButtonClick: undefined
})
const dialogStore = useDialogStore()
const handleConflictInfoClick = () => {
window.open(
'https://docs.comfy.org/troubleshooting/custom-node-issues',
'_blank'
)
}
const handleButtonClick = () => {
// Close the conflict dialog
dialogStore.closeDialog({ key: 'global-node-conflict' })
// Execute the custom button action if provided
if (props.onButtonClick) {
props.onButtonClick()
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-12 flex items-center justify-between w-full pl-6">
<div class="flex items-center gap-2">
<!-- Warning Icon -->
<i class="pi pi-exclamation-triangle text-lg"></i>
<!-- Title -->
<p class="text-base font-bold">
{{ $t('manager.conflicts.title') }}
</p>
</div>
</div>
</template>

View File

@@ -17,9 +17,10 @@
<script setup lang="ts">
import Message from 'primevue/message'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
type PackVersionStatus = components['schemas']['NodeVersionStatus']
type PackStatus = components['schemas']['NodeStatus']
@@ -32,10 +33,15 @@ type StatusProps = {
severity: MessageSeverity
}
const { statusType } = defineProps<{
const { statusType, hasCompatibilityIssues } = defineProps<{
statusType: Status
hasCompatibilityIssues?: boolean
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const statusPropsMap: Record<Status, StatusProps> = {
NodeStatusActive: {
label: 'active',
@@ -71,10 +77,14 @@ const statusPropsMap: Record<Status, StatusProps> = {
}
}
const statusLabel = computed(
() => statusPropsMap[statusType]?.label || 'unknown'
)
const statusSeverity = computed(
() => statusPropsMap[statusType]?.severity || 'secondary'
)
const statusLabel = computed(() => {
if (importFailed?.value) return 'importFailed'
if (hasCompatibilityIssues) return 'conflicting'
return statusPropsMap[statusType]?.label || 'unknown'
})
const statusSeverity = computed(() => {
if (hasCompatibilityIssues || importFailed?.value) return 'error'
return statusPropsMap[statusType]?.severity || 'secondary'
})
</script>

View File

@@ -6,11 +6,18 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import PackVersionBadge from './PackVersionBadge.vue'
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
@@ -120,7 +127,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -134,7 +141,7 @@ describe('PackVersionBadge', () => {
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
expect(badge.find('span').text()).toBe('nightly')
})
it('toggles the popover when button is clicked', async () => {

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@@ -12,8 +12,7 @@
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
@@ -22,7 +21,7 @@
<Popover
ref="popoverRef"
:pt="{
content: { class: 'px-0' }
content: { class: 'p-0 shadow-lg' }
}"
>
<PackVersionSelectorPopover
@@ -42,8 +41,7 @@ import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
@@ -64,11 +62,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const installedVersion = computed(() => {
if (!nodePack.id) return SelectedVersion.NIGHTLY
if (!nodePack.id) return 'nightly'
const version =
managerStore.installedPacks[nodePack.id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
'nightly'
// If Git hash, truncate to 7 characters
return isSemVer(version) ? version : version.slice(0, TRUNCATED_HASH_LENGTH)

View File

@@ -3,18 +3,32 @@ import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Listbox from 'primevue/listbox'
import Select from 'primevue/select'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import enMessages from '@/locales/en/main.json'
import { SelectedVersion } from '@/types/comfyManagerTypes'
// SelectedVersion is now using direct strings instead of enum
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
// Default mock versions for reference
const defaultMockVersions = [
{ version: '1.0.0', createdAt: '2023-01-01' },
{
version: '1.0.0',
createdAt: '2023-01-01',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
{ version: '0.9.0', createdAt: '2022-12-01' },
{ version: '0.8.0', createdAt: '2022-11-01' }
]
@@ -22,13 +36,24 @@ const defaultMockVersions = [
const mockNodePack = {
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' },
repository: 'https://github.com/user/repo'
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
},
repository: 'https://github.com/user/repo',
has_registry_data: true
}
// Create mock functions
const mockGetPackVersions = vi.fn()
const mockInstallPack = vi.fn().mockResolvedValue(undefined)
const mockCheckNodeCompatibility = vi.fn()
// Mock the registry service
vi.mock('@/services/comfyRegistryService', () => ({
@@ -49,6 +74,13 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
// Mock the conflict detection composable
vi.mock('@/composables/useConflictDetection', () => ({
useConflictDetection: vi.fn(() => ({
checkNodeCompatibility: mockCheckNodeCompatibility
}))
}))
const waitForPromises = async () => {
await new Promise((resolve) => setTimeout(resolve, 16))
await nextTick()
@@ -59,6 +91,9 @@ describe('PackVersionSelectorPopover', () => {
vi.clearAllMocks()
mockGetPackVersions.mockReset()
mockInstallPack.mockReset().mockResolvedValue(undefined)
mockCheckNodeCompatibility
.mockReset()
.mockReturnValue({ hasConflict: false, conflicts: [] })
})
const mountComponent = ({
@@ -78,7 +113,12 @@ describe('PackVersionSelectorPopover', () => {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Listbox
Listbox,
VerifiedIcon,
Select
},
directives: {
tooltip: Tooltip
}
}
})
@@ -120,14 +160,15 @@ describe('PackVersionSelectorPopover', () => {
const options = listbox.props('options')!
// Check that we have both special options and version options
expect(options.length).toBe(defaultMockVersions.length + 2) // 2 special options + version options
// Latest version (1.0.0) should be excluded from the version list to avoid duplication
expect(options.length).toBe(defaultMockVersions.length + 1) // 2 special options + version options minus 1 duplicate
// Check that special options exist
expect(options.some((o) => o.value === SelectedVersion.NIGHTLY)).toBe(true)
expect(options.some((o) => o.value === SelectedVersion.LATEST)).toBe(true)
expect(options.some((o) => o.value === 'nightly')).toBe(true)
expect(options.some((o) => o.value === 'latest')).toBe(true)
// Check that version options exist
expect(options.some((o) => o.value === '1.0.0')).toBe(true)
// Check that version options exist (excluding latest version 1.0.0)
expect(options.some((o) => o.value === '1.0.0')).toBe(false) // Should be excluded as it's the latest
expect(options.some((o) => o.value === '0.9.0')).toBe(true)
expect(options.some((o) => o.value === '0.8.0')).toBe(true)
})
@@ -304,7 +345,7 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
it('defaults to nightly when publisher name is "Unclaimed"', async () => {
@@ -325,7 +366,343 @@ describe('PackVersionSelectorPopover', () => {
await waitForPromises()
const listbox = wrapper.findComponent(Listbox)
expect(listbox.exists()).toBe(true)
expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY)
expect(listbox.props('modelValue')).toBe('nightly')
})
})
describe('version compatibility checking', () => {
it('shows warning icon for incompatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return conflict for specific version
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.supported_os?.includes('linux')) {
return {
hasConflict: true,
conflicts: [
{
type: 'os',
current_value: 'windows',
required_value: 'linux'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['linux'],
supported_accelerators: ['CUDA']
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for incompatible versions
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows verified icon for compatible versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return no conflicts
mockCheckNodeCompatibility.mockReturnValue({
hasConflict: false,
conflicts: []
})
const wrapper = mountComponent()
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The verified icon should be shown for compatible versions
// Look for the VerifiedIcon component or SVG elements
const verifiedIcons = wrapper.findAll('svg')
expect(verifiedIcons.length).toBeGreaterThan(0)
})
it('calls checkVersionCompatibility with correct version data', async () => {
// Set up the mock for versions with specific supported data
const versionsWithCompatibility = [
{
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CUDA', 'CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
]
mockGetPackVersions.mockResolvedValueOnce(versionsWithCompatibility)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
version: '1.0.0',
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Trigger compatibility check by accessing getVersionCompatibility
const vm = wrapper.vm as any
vm.getVersionCompatibility('1.0.0')
// Verify that checkNodeCompatibility was called with correct data
// Since 1.0.0 is the latest version, it should use latest_version data
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows', 'linux'],
supported_accelerators: ['CPU'], // latest_version data takes precedence
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
})
it('shows version conflict warnings for ComfyUI and frontend versions', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return version conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
const conflicts = []
if (versionData.supported_comfyui_version) {
conflicts.push({
type: 'comfyui_version',
current_value: '0.5.0',
required_value: versionData.supported_comfyui_version
})
}
if (versionData.supported_comfyui_frontend_version) {
conflicts.push({
type: 'frontend_version',
current_value: '1.0.0',
required_value: versionData.supported_comfyui_frontend_version
})
}
return {
hasConflict: conflicts.length > 0,
conflicts
}
})
const nodePackWithVersionRequirements = {
...mockNodePack,
supported_comfyui_version: '>=1.0.0',
supported_comfyui_frontend_version: '>=2.0.0'
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithVersionRequirements }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for version incompatible packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('handles latest and nightly versions using nodePack data', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const nodePackWithCompatibility = {
...mockNodePack,
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
latest_version: {
...mockNodePack.latest_version,
supported_os: ['windows'], // Match nodePack data for test consistency
supported_accelerators: ['CPU'], // Match nodePack data for test consistency
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true
}
}
const wrapper = mountComponent({
props: { nodePack: nodePackWithCompatibility }
})
await waitForPromises()
const vm = wrapper.vm as any
// Clear previous calls from component mounting/rendering
mockCheckNodeCompatibility.mockClear()
// Test latest version
vm.getVersionCompatibility('latest')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0'
})
// Clear for next test call
mockCheckNodeCompatibility.mockClear()
// Test nightly version
vm.getVersionCompatibility('nightly')
expect(mockCheckNodeCompatibility).toHaveBeenCalledWith({
id: 'test-pack',
name: 'Test Pack',
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0',
repository: 'https://github.com/user/repo',
has_registry_data: true,
latest_version: {
supported_os: ['windows'],
supported_accelerators: ['CPU'],
supported_python_version: '>=3.8',
is_banned: false,
has_registry_data: true,
version: '1.0.0',
supported_comfyui_version: '>=0.1.0',
supported_comfyui_frontend_version: '>=1.0.0'
}
})
})
it('shows banned package warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return banned conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.is_banned === true) {
return {
hasConflict: true,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const bannedNodePack = {
...mockNodePack,
is_banned: true,
latest_version: {
...mockNodePack.latest_version,
is_banned: true
}
}
const wrapper = mountComponent({
props: { nodePack: bannedNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// Open the dropdown to see the options
const select = wrapper.find('.p-select')
if (!select.exists()) {
// Try alternative selector
const selectButton = wrapper.find('[aria-haspopup="listbox"]')
if (selectButton.exists()) {
await selectButton.trigger('click')
}
} else {
await select.trigger('click')
}
await wrapper.vm.$nextTick()
// The warning icon should be shown for banned packages in the dropdown options
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
it('shows security pending warnings', async () => {
// Set up the mock for versions
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Mock compatibility check to return security pending conflicts
mockCheckNodeCompatibility.mockImplementation((versionData) => {
if (versionData.has_registry_data === false) {
return {
hasConflict: true,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
}
return { hasConflict: false, conflicts: [] }
})
const securityPendingNodePack = {
...mockNodePack,
has_registry_data: false,
latest_version: {
...mockNodePack.latest_version,
has_registry_data: false
}
}
const wrapper = mountComponent({
props: { nodePack: securityPendingNodePack }
})
await waitForPromises()
// Check that compatibility checking function was called
expect(mockCheckNodeCompatibility).toHaveBeenCalled()
// The warning icon should be shown for security pending packages
const warningIcons = wrapper.findAll('.pi-exclamation-triangle')
expect(warningIcons.length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,8 +1,10 @@
<template>
<div class="w-64 mt-2">
<span class="pl-3 text-muted text-md font-semibold opacity-70">
{{ $t('manager.selectVersion') }}
</span>
<div class="w-64 pt-1">
<div class="py-2">
<span class="pl-3 text-md font-semibold text-neutral-500">
{{ $t('manager.selectVersion') }}
</span>
</div>
<div
v-if="isLoadingVersions || isQueueing"
class="text-center text-muted py-4 flex flex-col items-center"
@@ -23,24 +25,44 @@
v-model="selectedVersion"
option-label="label"
option-value="value"
:options="versionOptions"
:options="processedVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
class="w-full max-h-[50vh] border-none shadow-none rounded-md"
:pt="{
listContainer: { class: 'scrollbar-hide' }
}"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">
<span>{{ slotProps.option.label }}</span>
<div class="flex items-center gap-2">
<template v-if="slotProps.option.value === 'nightly'">
<div class="w-4"></div>
</template>
<template v-else>
<i
v-if="slotProps.option.hasConflict"
v-tooltip="{
value: slotProps.option.conflictMessage,
showDelay: 300
}"
class="pi pi-exclamation-triangle text-yellow-500"
/>
<VerifiedIcon v-else :size="20" class="relative right-0.5" />
</template>
<span>{{ slotProps.option.label }}</span>
</div>
<i
v-if="selectedVersion === slotProps.option.value"
v-if="slotProps.option.isSelected"
class="pi pi-check text-highlight"
/>
</div>
</template>
</Listbox>
<ContentDivider class="my-2" />
<div class="flex justify-end gap-2 p-1 px-3">
<div class="flex justify-end gap-2 py-1 px-3">
<Button
text
class="text-sm"
severity="secondary"
:label="$t('g.cancel')"
:disabled="isQueueing"
@@ -49,7 +71,7 @@
<Button
severity="secondary"
:label="$t('g.install')"
class="py-3 px-4 dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
class="py-2.5 px-4 text-sm dark-theme:bg-unset bg-black/80 dark-theme:text-unset text-neutral-100 rounded-lg"
:disabled="isQueueing"
@click="handleSubmit"
/>
@@ -62,19 +84,18 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VerifiedIcon from '@/components/icons/VerifiedIcon.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
import { getJoinedConflictMessages } from '@/utils/conflictMessageUtil'
import { isSemVer } from '@/utils/formatUtil'
const { nodePack } = defineProps<{
@@ -89,27 +110,30 @@ const emit = defineEmits<{
const { t } = useI18n()
const registryService = useComfyRegistryService()
const managerStore = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const isQueueing = ref(false)
const selectedVersion = ref<string>(SelectedVersion.LATEST)
const selectedVersion = ref<string>('latest')
onMounted(() => {
const initialVersion = getInitialSelectedVersion() ?? SelectedVersion.LATEST
const initialVersion = getInitialSelectedVersion() ?? 'latest'
selectedVersion.value =
// Use NIGHTLY when version is a Git hash
isSemVer(initialVersion) ? initialVersion : SelectedVersion.NIGHTLY
isSemVer(initialVersion) ? initialVersion : 'nightly'
})
const getInitialSelectedVersion = () => {
if (!nodePack.id) return
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed') return SelectedVersion.NIGHTLY
// If node pack is installed, set selected version to the installed version
if (managerStore.isPackInstalled(nodePack.id))
return managerStore.getInstalledPackVersion(nodePack.id)
// If unclaimed, set selected version to nightly
if (nodePack.publisher?.name === 'Unclaimed')
return 'nightly' as ManagerComponents['schemas']['SelectedVersion']
// If node pack is not installed, set selected version to latest
return nodePack.latest_version?.version
}
@@ -126,6 +150,9 @@ const versionOptions = ref<
}[]
>([])
// Store fetched versions with their full data
const fetchedVersions = ref<components['schemas']['NodeVersion'][]>([])
const isLoadingVersions = ref(false)
const onNodePackChange = async () => {
@@ -133,26 +160,35 @@ const onNodePackChange = async () => {
// Fetch versions from the registry
const versions = await fetchVersions()
fetchedVersions.value = versions
// Get latest version number to exclude from the list
const latestVersionNumber = nodePack.latest_version?.version
const availableVersionOptions = versions
.map((version) => ({
value: version.version ?? '',
label: version.version ?? ''
}))
.filter((option) => option.value)
.filter((option) => option.value && option.value !== latestVersionNumber) // Exclude latest version from the list
// Add Latest option with actual version number
const latestLabel = latestVersionNumber
? `${t('manager.latestVersion')} (${latestVersionNumber})`
: t('manager.latestVersion')
// Add Latest option
const defaultVersions = [
{
value: SelectedVersion.LATEST,
label: t('manager.latestVersion')
value: 'latest' as ManagerComponents['schemas']['SelectedVersion'],
label: latestLabel
}
]
// Add Nightly option if there is a non-empty `repository` field
if (nodePack.repository?.length) {
defaultVersions.push({
value: SelectedVersion.NIGHTLY,
label: t('manager.nightlyVersion')
value: 'nightly' as ManagerComponents['schemas']['SelectedVersion'],
label: t('manager.nightlyVersion') // Keep as just "nightly" - no version number
})
}
@@ -172,16 +208,95 @@ whenever(
const handleSubmit = async () => {
isQueueing.value = true
if (!nodePack.id) {
throw new Error('Node ID is required for installation')
}
// Convert 'latest' to actual version number for installation
const actualVersion =
selectedVersion.value === 'latest'
? nodePack.latest_version?.version ?? 'latest'
: selectedVersion.value
await managerStore.installPack.call({
id: nodePack.id,
version: actualVersion,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: ManagerDatabaseSource.CACHE,
version: selectedVersion.value,
selected_version: selectedVersion.value
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: actualVersion
})
isQueueing.value = false
emit('submit')
}
const getVersionData = (version: string) => {
const latestVersionNumber = nodePack.latest_version?.version
const useLatestVersionData =
version === 'latest' || version === latestVersionNumber
if (useLatestVersionData) {
const latestVersionData = nodePack.latest_version
return {
...latestVersionData
}
}
const versionData = fetchedVersions.value.find((v) => v.version === version)
if (versionData) {
return {
...versionData
}
}
// Fallback to nodePack data
return {
...nodePack
}
}
// Main function to get version compatibility info
const getVersionCompatibility = (version: string) => {
const versionData = getVersionData(version)
const compatibility = checkNodeCompatibility(versionData)
const conflictMessage = compatibility.hasConflict
? getJoinedConflictMessages(compatibility.conflicts, t)
: ''
return {
hasConflict: compatibility.hasConflict,
conflictMessage
}
}
// Helper to determine if an option is selected.
const isOptionSelected = (optionValue: string) => {
if (selectedVersion.value === optionValue) {
return true
}
if (
optionValue === 'latest' &&
selectedVersion.value === nodePack.latest_version?.version
) {
return true
}
return false
}
// Checks if an option is selected, treating 'latest' as an alias for the actual latest version number.
const processedVersionOptions = computed(() => {
return versionOptions.value.map((option) => {
const compatibility = getVersionCompatibility(option.value)
const isSelected = isOptionSelected(option.value)
return {
...option,
hasConflict: compatibility.hasConflict,
conflictMessage: compatibility.conflictMessage,
isSelected: isSelected
}
})
})
</script>

View File

@@ -1,18 +1,24 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
class="!m-0 p-0 max-w-[120px] rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
: variant === 'red'
? 'border-red-500'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2 px-3 whitespace-nowrap">
<span class="py-1.5 px-3 whitespace-nowrap text-xs flex items-center gap-2">
<i
v-if="hasWarning && !loading"
class="pi pi-exclamation-triangle text-yellow-500"
></i>
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -28,15 +34,18 @@ import Button from 'primevue/button'
const {
label,
loading = false,
loadingMessage,
fullWidth = false,
variant = 'default'
variant = 'default',
hasWarning = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
variant?: 'default' | 'black' | 'red'
hasWarning?: boolean
}>()
const emit = defineEmits<{

View File

@@ -11,9 +11,22 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
// Mock lodash functions used throughout the app
vi.mock('lodash', async (importOriginal) => {
const actual = await importOriginal<typeof import('lodash')>()
return {
...actual,
debounce: <T extends (...args: any[]) => any>(fn: T) => fn,
memoize: <T extends (...args: any[]) => any>(fn: T) => fn
}
})
// Mock config to prevent __COMFYUI_FRONTEND_VERSION__ error
vi.mock('@/config', () => ({
default: {
app_title: 'ComfyUI',
app_version: '1.0.0'
}
}))
const mockNodePack = {

View File

@@ -1,6 +1,26 @@
<template>
<div class="flex items-center">
<div class="flex items-center gap-2">
<div
v-if="hasConflict"
v-tooltip="{
value: $t('manager.conflicts.warningTooltip'),
showDelay: 300
}"
class="flex items-center justify-center w-6 h-6 cursor-pointer"
@click="showConflictModal(true)"
>
<i class="pi pi-exclamation-triangle text-yellow-500 text-xl"></i>
</div>
<ToggleSwitch
v-if="!canToggleDirectly"
:model-value="isEnabled"
:disabled="isLoading"
:readonly="!canToggleDirectly"
aria-label="Enable or disable pack"
@focus="handleToggleInteraction"
/>
<ToggleSwitch
v-else
:model-value="isEnabled"
:disabled="isLoading"
aria-label="Enable or disable pack"
@@ -13,52 +33,96 @@
import { debounce } from 'lodash'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstallPackParams,
ManagerChannel,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
const TOGGLE_DEBOUNCE_MS = 256
const { nodePack } = defineProps<{
const { nodePack, hasConflict } = defineProps<{
nodePack: components['schemas']['Node']
hasConflict?: boolean
}>()
const { isPackEnabled, enablePack, disablePack, installedPacks } =
useComfyManagerStore()
const { t } = useI18n()
const { isPackEnabled, enablePack, disablePack } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { showNodeConflictDialog } = useDialogService()
const { acknowledgmentState, markConflictsAsSeen } = useConflictAcknowledgment()
const isLoading = ref(false)
const isEnabled = computed(() => isPackEnabled(nodePack.id))
const version = computed(() => {
const id = nodePack.id
if (!id) return SelectedVersion.NIGHTLY
return (
installedPacks[id]?.ver ??
nodePack.latest_version?.version ??
SelectedVersion.NIGHTLY
const packageConflict = computed(() =>
getConflictsForPackageByID(nodePack.id || '')
)
const canToggleDirectly = computed(() => {
return !(
hasConflict &&
!acknowledgmentState.value.modal_dismissed &&
packageConflict.value
)
})
const handleEnable = () =>
enablePack.call({
id: nodePack.id,
version: version.value,
selected_version: version.value,
repository: nodePack.repository ?? '',
channel: ManagerChannel.DEFAULT,
mode: 'default' as InstallPackParams['mode']
})
const showConflictModal = (skipModalDismissed: boolean) => {
let modal_dismissed = acknowledgmentState.value.modal_dismissed
if (skipModalDismissed) modal_dismissed = false
if (packageConflict.value && !modal_dismissed) {
showNodeConflictDialog({
conflictedPackages: [packageConflict.value],
buttonText: !isEnabled.value
? t('manager.conflicts.enableAnyway')
: t('manager.conflicts.understood'),
onButtonClick: async () => {
if (!isEnabled.value) {
await handleEnable()
}
},
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
}
const handleDisable = () =>
disablePack({
const handleEnable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for enabling')
}
return enablePack.call({
id: nodePack.id,
version: version.value
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
selected_version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion']),
repository: nodePack.repository ?? '',
channel: 'default' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
skip_post_install: false
})
}
const handleDisable = () => {
if (!nodePack.id) {
throw new Error('Node ID is required for disabling')
}
return disablePack({
id: nodePack.id,
version:
nodePack.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
})
}
const handleToggle = async (enable: boolean) => {
if (isLoading.value) return
@@ -67,10 +131,23 @@ const handleToggle = async (enable: boolean) => {
if (enable) {
await handleEnable()
} else {
handleDisable()
await handleDisable()
}
isLoading.value = false
}
const onToggle = debounce(handleToggle, TOGGLE_DEBOUNCE_MS, { trailing: true })
const onToggle = debounce(
(enable: boolean) => {
void handleToggle(enable)
},
TOGGLE_DEBOUNCE_MS,
{ trailing: true }
)
const handleToggleInteraction = async (event: Event) => {
if (!canToggleDirectly.value) {
event.preventDefault()
showConflictModal(false)
}
}
</script>

View File

@@ -9,8 +9,8 @@
:variant="variant"
:loading="isInstalling"
:loading-message="$t('g.installing')"
:has-warning="hasConflict"
@action="installAllPacks"
@click="onClick"
/>
</template>
@@ -18,44 +18,51 @@
import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import {
type ConflictDetail,
type ConflictDetectionResult
} from '@/types/conflictDetectionTypes'
import { components as ManagerComponents } from '@/types/generatedManagerTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks, variant, label, hasConflict, conflictInfo } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
hasConflict?: boolean
conflictInfo?: ConflictDetail[]
}>()
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isInstalling.value = true
}
const managerStore = useComfyManagerStore()
const { showNodeConflictDialog } = useDialogService()
const createPayload = (
installItem: NodePack
): ManagerComponents['schemas']['InstallPackParams'] => {
if (!installItem.id) {
throw new Error('Node ID is required for installation')
}
const createPayload = (installItem: NodePack) => {
const isUnclaimedPack = installItem.publisher?.name === 'Unclaimed'
const versionToInstall = isUnclaimedPack
? SelectedVersion.NIGHTLY
: installItem.latest_version?.version ?? SelectedVersion.LATEST
? ('nightly' as ManagerComponents['schemas']['SelectedVersion'])
: installItem.latest_version?.version ??
('latest' as ManagerComponents['schemas']['SelectedVersion'])
return {
id: installItem.id,
version: versionToInstall,
repository: installItem.repository ?? '',
channel: ManagerChannel.DEV,
mode: ManagerDatabaseSource.CACHE,
selected_version: versionToInstall,
version: versionToInstall
channel: 'dev' as ManagerComponents['schemas']['ManagerChannel'],
mode: 'cache' as ManagerComponents['schemas']['ManagerDatabaseSource'],
selected_version: versionToInstall
}
}
@@ -65,14 +72,35 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
if (hasConflict && conflictInfo) {
const conflictedPackages: ConflictDetectionResult[] = nodePacks.map(
(pack) => ({
package_id: pack.id || '',
package_name: pack.name || '',
has_conflict: true,
conflicts: conflictInfo || [],
is_compatible: false
})
)
showNodeConflictDialog({
conflictedPackages,
buttonText: t('manager.conflicts.installAnyway'),
onButtonClick: async () => {
// Proceed with installation
isInstalling.value = true
await performInstallation(nodePacks)
}
})
return
}
// No conflicts or conflicts acknowledged - proceed with installation
isInstalling.value = true
await performInstallation(nodePacks)
}
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)
if (!uninstalledPacks.length) return
await Promise.all(uninstalledPacks.map(installPack))
const performInstallation = async (packs: NodePack[]) => {
await Promise.all(packs.map(installPack))
managerStore.installPack.clear()
}
</script>

View File

@@ -6,7 +6,7 @@
? $t('manager.uninstallSelected')
: $t('manager.uninstall')
"
severity="danger"
variant="red"
:loading-message="$t('manager.uninstalling')"
@action="uninstallItems"
/>
@@ -15,7 +15,6 @@
<script setup lang="ts">
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { ManagerPackInfo } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
@@ -26,16 +25,16 @@ const { nodePacks } = defineProps<{
const managerStore = useComfyManagerStore()
const createPayload = (uninstallItem: NodePack): ManagerPackInfo => {
return {
id: uninstallItem.id,
version: uninstallItem.latest_version?.version
const uninstallPack = (item: NodePack) => {
if (!item.id) {
throw new Error('Node ID is required for uninstallation')
}
return managerStore.uninstallPack({
id: item.id,
version: item.latest_version?.version ?? ''
})
}
const uninstallPack = (item: NodePack) =>
managerStore.uninstallPack(createPayload(item))
const uninstallItems = async () => {
if (!nodePacks?.length) return
await Promise.all(nodePacks.map(uninstallPack))

View File

@@ -0,0 +1,78 @@
<template>
<PackActionButton
v-bind="$attrs"
variant="black"
:label="$t('manager.updateAll')"
:loading="isUpdating"
:loading-message="$t('g.updating')"
@action="updateAllPacks"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isUpdating = ref<boolean>(false)
const managerStore = useComfyManagerStore()
const createPayload = (updateItem: NodePack) => {
return {
id: updateItem.id!,
version: updateItem.latest_version!.version!
}
}
const updatePack = async (item: NodePack) => {
if (!item.id || !item.latest_version?.version) {
console.warn('Pack missing required id or version:', item)
return
}
await managerStore.updatePack.call(createPayload(item))
}
const updateAllPacks = async () => {
if (!nodePacks?.length) {
console.warn('No packs provided for update')
return
}
isUpdating.value = true
const updatablePacks = nodePacks.filter((pack) =>
managerStore.isPackInstalled(pack.id)
)
if (!updatablePacks.length) {
console.info('No installed packs available for update')
isUpdating.value = false
return
}
console.info(`Starting update of ${updatablePacks.length} packs`)
try {
await Promise.all(updatablePacks.map(updatePack))
managerStore.updatePack.clear()
console.info('All packs updated successfully')
} catch (error) {
console.error('Pack update failed:', error)
console.error(
'Failed packs info:',
updatablePacks.map((p) => p.id)
)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -2,20 +2,26 @@
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
</div>
<div
ref="scrollContainer"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar"
class="p-6 pt-2 overflow-y-auto flex-1 text-sm scrollbar-hide"
>
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
v-if="!importFailed && isPackInstalled(nodePack.id)"
:label="t('manager.filter.enabled')"
class="flex"
style="align-items: center"
>
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow
v-for="item in infoItems"
@@ -29,6 +35,7 @@
:status-type="
nodePack.status as components['schemas']['NodeVersionStatus']
"
:has-compatibility-issues="hasCompatibilityIssues"
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
@@ -36,7 +43,11 @@
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
<InfoTabs :node-pack="nodePack" />
<InfoTabs
:node-pack="nodePack"
:has-compatibility-issues="hasCompatibilityIssues"
:conflict-result="conflictResult"
/>
</div>
</div>
</div>
@@ -59,9 +70,14 @@ import PackEnableToggle from '@/components/dialog/content/manager/button/PackEna
import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
interface InfoItem {
key: string
@@ -75,18 +91,55 @@ const { nodePack } = defineProps<{
const scrollContainer = ref<HTMLElement | null>(null)
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { t, d, n } = useI18n()
// Check compatibility once and pass to children
const conflictResult = computed((): ConflictDetectionResult | null => {
// For installed packages, use stored conflict data
if (isInstalled.value && nodePack.id) {
return getConflictsForPackageByID(nodePack.id) || null
}
// For non-installed packages, perform compatibility check
const compatibility = checkNodeCompatibility(nodePack)
if (compatibility.hasConflict) {
return {
package_id: nodePack.id || '',
package_name: nodePack.name || '',
has_conflict: true,
conflicts: compatibility.conflicts,
is_compatible: false
}
}
return null
})
const hasCompatibilityIssues = computed(() => {
return conflictResult.value?.has_conflict
})
const packageId = computed(() => nodePack.id || '')
const { importFailed, showImportFailedDialog } =
useImportFailedDetection(packageId)
provide(ImportFailedKey, {
importFailed,
showImportFailedDialog
})
const infoItems = computed<InfoItem[]>(() => [
{
key: 'publisher',
@@ -128,17 +181,3 @@ whenever(
{ immediate: true }
)
</script>
<style scoped>
.hidden-scrollbar {
/* Firefox */
scrollbar-width: none;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
</style>

View File

@@ -1,28 +1,36 @@
<template>
<div v-if="nodePacks?.length" class="flex flex-col items-center mb-6">
<div v-if="nodePacks?.length" class="flex flex-col items-center">
<slot name="thumbnail">
<PackIcon :node-pack="nodePacks[0]" width="24" height="24" />
<PackIcon :node-pack="nodePacks[0]" width="204" height="106" />
</slot>
<h2
class="text-2xl font-bold text-center mt-4 mb-2"
style="word-break: break-all"
>
<slot name="title">
{{ nodePacks[0].name }}
<span class="inline-block text-base">{{ nodePacks[0].name }}</span>
</slot>
</h2>
<div class="mt-2 mb-4 w-full max-w-xs flex justify-center">
<div
v-if="!importFailed"
class="mt-2 mb-4 w-full max-w-xs flex justify-center"
>
<slot name="install-button">
<PackUninstallButton
v-if="isAllInstalled"
v-bind="$attrs"
:node-packs="nodePacks"
/>
<PackInstallButton v-else v-bind="$attrs" :node-packs="nodePacks" />
<PackInstallButton
v-else
v-bind="$attrs"
:node-packs="nodePacks"
:has-conflict="hasConflict"
/>
</slot>
</div>
</div>
<div v-else class="flex flex-col items-center mb-6">
<div v-else class="flex flex-col items-center">
<NoResultsPlaceholder
:message="$t('manager.status.unknown')"
:title="$t('manager.tryAgainLater')"
@@ -31,7 +39,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { inject, ref, watch } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
@@ -39,13 +47,19 @@ import PackUninstallButton from '@/components/dialog/content/manager/button/Pack
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { components } from '@/types/comfyRegistryTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePacks } = defineProps<{
const { nodePacks, hasConflict } = defineProps<{
nodePacks: components['schemas']['Node'][]
hasConflict?: boolean
}>()
const managerStore = useComfyManagerStore()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const isAllInstalled = ref(false)
watch(
[() => nodePacks, () => managerStore.installedPacks],

View File

@@ -6,8 +6,12 @@
<PackIconStacked :node-packs="nodePacks" />
</template>
<template #title>
{{ nodePacks.length }}
{{ $t('manager.packsSelected') }}
<div class="mt-5">
<span class="inline-block mr-2 text-blue-500 text-base">{{
nodePacks.length
}}</span>
<span class="text-base">{{ $t('manager.packsSelected') }}</span>
</div>
</template>
<template #install-button>
<PackInstallButton :full-width="true" :node-packs="nodePacks" />

View File

@@ -1,15 +1,31 @@
<template>
<div class="overflow-hidden">
<Tabs :value="activeTab">
<TabList>
<Tab value="description">
<TabList class="overflow-x-auto scrollbar-hide">
<Tab v-if="hasCompatibilityIssues" value="warning" class="p-2 mr-6">
<div class="flex items-center gap-1">
<span></span>
{{ importFailed ? $t('g.error') : $t('g.warning') }}
</div>
</Tab>
<Tab value="description" class="p-2 mr-6">
{{ $t('g.description') }}
</Tab>
<Tab value="nodes">
<Tab value="nodes" class="p-2">
{{ $t('g.nodes') }}
</Tab>
</TabList>
<TabPanels class="overflow-auto">
<TabPanels class="overflow-auto py-4 px-2">
<TabPanel
v-if="hasCompatibilityIssues"
value="warning"
class="bg-transparent"
>
<WarningTabPanel
:node-pack="nodePack"
:conflict-result="conflictResult"
/>
</TabPanel>
<TabPanel value="description">
<DescriptionTabPanel :node-pack="nodePack" />
</TabPanel>
@@ -27,16 +43,25 @@ import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, ref } from 'vue'
import { computed, inject, ref, watchEffect } from 'vue'
import DescriptionTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/DescriptionTabPanel.vue'
import NodesTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/NodesTabPanel.vue'
import WarningTabPanel from '@/components/dialog/content/manager/infoPanel/tabs/WarningTabPanel.vue'
import { components } from '@/types/comfyRegistryTypes'
import type { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/types/importFailedTypes'
const { nodePack } = defineProps<{
const { nodePack, hasCompatibilityIssues, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
hasCompatibilityIssues?: boolean
conflictResult?: ConflictDetectionResult | null
}>()
// Inject import failed context from parent
const importFailedContext = inject(ImportFailedKey)
const importFailed = importFailedContext?.importFailed
const nodeNames = computed(() => {
// @ts-expect-error comfy_nodes is an Algolia-specific field
const { comfy_nodes } = nodePack
@@ -44,4 +69,17 @@ const nodeNames = computed(() => {
})
const activeTab = ref('description')
// Watch for compatibility issues and automatically switch to warning tab
watchEffect(
() => {
if (hasCompatibilityIssues) {
activeTab.value = 'warning'
} else if (activeTab.value === 'warning') {
// If currently on warning tab but no issues, switch to description
activeTab.value = 'description'
}
},
{ flush: 'post' }
)
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-4 text-sm">
<div v-for="(section, index) in sections" :key="index" class="mb-4">
<div class="mb-1">
<div class="mb-3">
{{ section.title }}
</div>
<div class="text-muted break-words">

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex py-1.5 text-xs">
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}:</div>
<div class="w-1/3 truncate pr-2 text-muted">{{ label }}</div>
<div class="w-2/3">
<slot>{{ value }}</slot>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="mt-4 overflow-hidden">
<div class="overflow-hidden">
<InfoTextSection
v-if="nodePack?.description"
:sections="descriptionSections"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-4 mt-4 text-sm">
<div class="flex flex-col gap-4 text-sm">
<template v-if="mappedNodeDefs?.length">
<div
v-for="nodeDef in mappedNodeDefs"

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col gap-3">
<button
v-if="importFailedInfo"
class="cursor-pointer outline-none border-none inline-flex items-center justify-end bg-transparent gap-1"
@click="showImportFailedDialog"
>
<i class="pi pi-code text-base"></i>
<span class="dark-theme:text-white text-sm">{{
t('serverStart.openLogs')
}}</span>
</button>
<div
v-for="(conflict, index) in conflictResult?.conflicts || []"
:key="index"
class="p-3 bg-yellow-800/20 rounded-md"
>
<div class="flex justify-between items-center">
<div class="text-sm break-words flex-1">
{{ getConflictMessage(conflict, $t) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { t } from '@/i18n'
import { components } from '@/types/comfyRegistryTypes'
import { ConflictDetectionResult } from '@/types/conflictDetectionTypes'
import { getConflictMessage } from '@/utils/conflictMessageUtil'
const { nodePack, conflictResult } = defineProps<{
nodePack: components['schemas']['Node']
conflictResult: ConflictDetectionResult | null | undefined
}>()
const packageId = computed(() => nodePack?.id || '')
const { importFailedInfo, showImportFailedDialog } =
useImportFailedDetection(packageId)
</script>

View File

@@ -43,7 +43,7 @@
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden max-h-12 line-clamp-3 my-0 leading-4 mb-2 overflow-hidden"
>
{{ nodePack.description }}
</p>
@@ -119,12 +119,20 @@ provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
const isDisabled = ref(false)
const managerStore = useComfyManagerStore()
whenever(isInstalled, () => (isInstalling.value = false))
// Watch the installedPacks object directly (which gets updated from WebSocket)
whenever(
() => managerStore.installedPacksIds,
() => {
const isInstalled = isPackInstalled(nodePack?.id)
isDisabled.value = isInstalled && !isPackEnabled(nodePack?.id)
// Update isInstalling state after installation is complete
if (isInstalling.value && isInstalled) isInstalling.value = false
}
)
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined

View File

@@ -1,13 +1,39 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="h-12 flex justify-between items-center px-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
<div class="flex justify-end items-center gap-2">
<template v-if="importFailed">
<div
class="flex justify-center items-center gap-2 cursor-pointer"
@click="showImportFailedDialog"
>
<i class="pi pi-exclamation-triangle text-red-500 text-sm"></i>
<span class="text-red-500 text-xs pt-0.5">{{
t('manager.failedToInstall')
}}</span>
</div>
</template>
<template v-else>
<template v-if="!isInstalled">
<PackInstallButton
:node-packs="[nodePack]"
:has-conflict="uninstalledPackConflict.hasConflict"
:conflict-info="uninstalledPackConflict.conflicts"
/>
</template>
<template v-else>
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="installedPackHasConflict"
/>
</template>
</template>
</div>
</div>
</template>
@@ -17,7 +43,10 @@ import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useImportFailedDetection } from '@/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -27,9 +56,29 @@ const { nodePack } = defineProps<{
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const { n } = useI18n()
const { n, t } = useI18n()
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { getConflictsForPackageByID } = useConflictDetectionStore()
const { checkNodeCompatibility } = useConflictDetection()
const { importFailed, showImportFailedDialog } = useImportFailedDetection(
nodePack.id
)
const conflicts = computed(
() => getConflictsForPackageByID(nodePack.id!) || null
)
const installedPackHasConflict = computed(() => {
if (!nodePack.id) return false
return !!conflicts.value
})
const uninstalledPackConflict = computed(() => {
return checkNodeCompatibility(nodePack)
})
</script>

View File

@@ -20,7 +20,10 @@
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
<PackEnableToggle
:node-pack="nodePack"
:has-conflict="!!packageConflicts"
/>
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
@@ -35,6 +38,7 @@ import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
@@ -42,5 +46,13 @@ const { nodePack } = defineProps<{
}>()
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const packageConflicts = computed(() => {
if (!nodePack.id || !isInstalled.value) return null
// For installed packages, check conflicts from store
return getConflictsForPackageByID(nodePack.id)
})
</script>

View File

@@ -1,11 +1,37 @@
<template>
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
<div class="w-full max-w-[204] aspect-[2/1] rounded-lg overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
@@ -13,29 +39,14 @@ import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_ICON = '/assets/images/fallback-gradient-avatar.svg'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '4.5rem',
height = '4.5rem'
} = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.icon || nodePack.icon.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_ICON : nodePack.icon
)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
</script>

View File

@@ -1,25 +1,19 @@
<template>
<div class="relative w-24 h-24">
<div class="relative w-[224px] h-[104px] shadow-xl">
<div
v-for="(pack, index) in nodePacks.slice(0, maxVisible)"
:key="pack.id"
class="absolute"
class="absolute w-[210px] h-[90px]"
:style="{
bottom: `${index * offset}px`,
right: `${index * offset}px`,
zIndex: maxVisible - index
}"
>
<div class="border rounded-lg p-0.5">
<PackIcon :node-pack="pack" width="4.5rem" height="4.5rem" />
<div class="border rounded-lg shadow-lg p-0.5">
<PackIcon :node-pack="pack" />
</div>
</div>
<div
v-if="nodePacks.length > maxVisible"
class="absolute -top-2 -right-2 bg-primary rounded-full w-7 h-7 flex items-center justify-center text-xs font-bold shadow-md z-10"
>
+{{ nodePacks.length - maxVisible }}
</div>
</div>
</template>

View File

@@ -33,6 +33,10 @@
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
/>
<PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
@@ -65,8 +69,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes'
import {
type SearchOption,
SortableAlgoliaField
@@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
isUpdateAvailableTab?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery')
@@ -96,6 +103,10 @@ const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } =
useUpdateAvailableNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)

View File

@@ -1,41 +1,44 @@
<template>
<div
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
class="w-full px-6 py-2 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} {{ $t('g.progressCountOf') }}
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress"
v-if="!isInProgress && !isRestartCompleted"
rounded
outlined
class="px-4 py-2 rounded-md mr-4"
class="mr-4 rounded-md border-2 px-3 text-neutral-600 border-neutral-900 hover:bg-neutral-100 !dark-theme:bg-transparent dark-theme:text-white dark-theme:border-white dark-theme:hover:bg-neutral-800"
@click="handleRestart"
>
{{ $t('g.restart') }}
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
@@ -44,6 +47,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
@@ -53,6 +57,7 @@
text
rounded
size="small"
class="font-bold"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
@@ -65,9 +70,10 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useWorkflowService } from '@/services/workflowService'
@@ -77,41 +83,93 @@ import {
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useSettingStore } from '@/stores/settingStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('g.installing')
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
const onReconnect = async () => {
// Refresh manager state
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
// Refresh node definitions
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
isRestarting.value = true
// Reload workflow
await useWorkflowService().reloadCurrentWorkflow()
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
closeDialog()
}
</script>

View File

@@ -18,16 +18,40 @@
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value: number) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = [
{ label: t('manager.installationQueue') },
{ label: t('manager.failed', { count: 0 }) }
]
const failedCount = computed(() => comfyManagerStore.failedTasksIds.length)
const queueSuffix = computed(() => {
const queueLength = comfyManagerStore.managerQueue.queueLength
if (queueLength === 0) {
return ''
}
return ` (${queueLength})`
})
const failedSuffix = computed(() => {
if (failedCount.value === 0) {
return ''
}
return ` (${failedCount.value})`
})
const tabs = computed(() => [
{ label: t('manager.installationQueue') + queueSuffix.value },
{ label: t('manager.failed') + failedSuffix.value }
])
</script>

View File

@@ -17,28 +17,26 @@ import { createBounds } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useCanvasStore } from '@/stores/graphStore'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = () => {
const selectableItems = getSelectableItems()
showBorder.value = selectableItems.size > 1
const { selectedItems } = canvasStore.getCanvas()
showBorder.value = selectedItems.size > 1
if (!selectableItems.size) {
if (!selectedItems.size) {
visible.value = false
return
}
visible.value = true
const bounds = createBounds(selectableItems)
const bounds = createBounds(selectedItems)
if (bounds) {
updatePosition({
pos: [bounds[0], bounds[1]],
@@ -47,6 +45,7 @@ const positionSelectionOverlay = () => {
}
}
// Register listener on canvas creation.
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {

View File

@@ -1,122 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useCanvasStore } from '@/stores/graphStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the litegraph module
vi.mock('@comfyorg/litegraph', async () => {
const actual = await vi.importActual('@comfyorg/litegraph')
return {
...actual,
LGraphCanvas: {
node_colors: {
red: { bgcolor: '#ff0000' },
green: { bgcolor: '#00ff00' },
blue: { bgcolor: '#0000ff' }
}
},
LiteGraph: {
NODE_DEFAULT_BGCOLOR: '#353535'
},
isColorable: vi.fn(() => true)
}
})
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
}))
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
getItemsColorOption: vi.fn(() => null),
isLGraphNode: vi.fn((item) => item?.type === 'LGraphNode'),
isLGraphGroup: vi.fn((item) => item?.type === 'LGraphGroup'),
isReroute: vi.fn(() => false)
}))
describe('ColorPickerButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let workflowStore: ReturnType<typeof useWorkflowStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
color: {
noColor: 'No Color',
red: 'Red',
green: 'Green',
blue: 'Blue'
}
}
}
})
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
workflowStore = useWorkflowStore()
// Set up default store state
canvasStore.selectedItems = []
// Mock workflow store
workflowStore.activeWorkflow = {
changeTracker: {
checkState: vi.fn()
}
} as any
})
const createWrapper = () => {
return mount(ColorPickerButton, {
global: {
plugins: [PrimeVue, i18n],
directives: {
tooltip: Tooltip
}
}
})
}
it('should render when nodes are selected', () => {
// Add a mock node to selectedItems
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
expect(wrapper.find('button').exists()).toBe(true)
})
it('should not render when nothing is selected', () => {
// Keep selectedItems empty
canvasStore.selectedItems = []
const wrapper = createWrapper()
// The button exists but is hidden with v-show
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button').attributes('style')).toContain(
'display: none'
)
})
it('should toggle color picker visibility on button click', async () => {
canvasStore.selectedItems = [{ type: 'LGraphNode' } as any]
const wrapper = createWrapper()
const button = wrapper.find('button')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(true)
await button.trigger('click')
expect(wrapper.find('.color-picker-container').exists()).toBe(false)
})
})

View File

@@ -2,10 +2,6 @@
<div class="relative">
<Button
v-show="canvasStore.nodeSelected || canvasStore.groupSelected"
v-tooltip.top="{
value: localizedCurrentColorName ?? t('color.noColor'),
showDelay: 512
}"
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"
@@ -127,16 +123,6 @@ const currentColor = computed(() =>
: null
)
const localizedCurrentColorName = computed(() => {
if (!currentColorOption.value?.bgcolor) return null
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
option.value.light === currentColorOption.value?.bgcolor
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {

View File

@@ -10,7 +10,7 @@
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
>
<template #icon>
<i-lucide:shrink />
<i-comfy:workflow />
</template>
</Button>
</template>

View File

@@ -14,7 +14,17 @@
@mouseenter="onMenuItemHover(menuItem.key, $event)"
@mouseleave="onMenuItemLeave(menuItem.key)"
>
<i :class="menuItem.icon" class="help-menu-icon" />
<div class="help-menu-icon-container">
<div class="help-menu-icon">
<component
:is="menuItem.icon"
v-if="typeof menuItem.icon === 'object'"
:size="16"
/>
<i v-else :class="menuItem.icon" />
</div>
<div v-if="menuItem.showRedDot" class="menu-red-dot" />
</div>
<span class="menu-label">{{ menuItem.label }}</span>
<i v-if="menuItem.key === 'more'" class="pi pi-chevron-right" />
</button>
@@ -119,10 +129,21 @@
</template>
<script setup lang="ts">
// No need to import useStorage anymore - it's in the composable
import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import {
type CSSProperties,
type Component,
computed,
nextTick,
onMounted,
ref
} from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
@@ -133,12 +154,13 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
// Types
interface MenuItem {
key: string
icon?: string
icon?: string | Component
label?: string
action?: () => void
visible?: boolean
type?: 'item' | 'divider'
items?: MenuItem[]
showRedDot?: boolean
}
// Constants
@@ -170,6 +192,7 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits
const emit = defineEmits<{
@@ -192,6 +215,10 @@ const moreMenuItem = computed(() =>
menuItems.value.find((item) => item.key === 'more')
)
// Use conflict acknowledgment state from composable
const { shouldShowRedDot: shouldShowManagerRedDot } =
useConflictAcknowledgment()
const menuItems = computed<MenuItem[]>(() => {
const moreItems: MenuItem[] = [
{
@@ -271,6 +298,17 @@ const menuItems = computed<MenuItem[]>(() => {
emit('close')
}
},
{
key: 'manager',
type: 'item',
icon: PuzzleIcon,
label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value,
action: () => {
dialogService.showManagerDialog()
emit('close')
}
},
{
key: 'more',
type: 'item',
@@ -505,16 +543,39 @@ onMounted(async () => {
box-shadow: none;
}
.help-menu-icon {
.help-menu-icon-container {
position: relative;
margin-right: 0.75rem;
width: 16px;
flex-shrink: 0;
}
.help-menu-icon {
font-size: 1rem;
color: var(--p-text-muted-color);
width: 16px;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
}
.help-menu-icon svg {
color: var(--p-text-muted-color);
}
.menu-red-dot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #ff3b30;
border-radius: 50%;
border: 1.5px solid var(--p-content-background);
z-index: 1;
}
.menu-label {
flex: 1;
}

View File

@@ -75,6 +75,11 @@ import { formatVersionAnchor } from '@/utils/formatUtil'
const { locale, t } = useI18n()
const releaseStore = useReleaseStore()
// Emit event for parent component
const emit = defineEmits<{
'whats-new-dismissed': []
}>()
// Local state for dismissed status
const isDismissed = ref(false)
@@ -134,6 +139,10 @@ const closePopup = async () => {
await releaseStore.handleWhatsNewSeen(latestRelease.value.version)
}
hide()
// Emit event to notify parent that What's New was dismissed
// Parent can then check if conflict modal should be shown
emit('whats-new-dismissed')
}
// Learn more handled by anchor href

View File

@@ -0,0 +1,43 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<g clip-path="url(#clip0_1099_16244)">
<path
d="M4.99992 3.00016C4.99992 2.07969 5.74611 1.3335 6.66658 1.3335C7.58706 1.3335 8.33325 2.07969 8.33325 3.00016V4.00016H8.99992C9.9318 4.00016 10.3977 4.00016 10.7653 4.1524C11.2553 4.35539 11.6447 4.74474 11.8477 5.2348C11.9999 5.60234 11.9999 6.06828 11.9999 7.00016H12.9999C13.9204 7.00016 14.6666 7.74635 14.6666 8.66683C14.6666 9.5873 13.9204 10.3335 12.9999 10.3335H11.9999V11.4668C11.9999 12.5869 11.9999 13.147 11.7819 13.5748C11.5902 13.9511 11.2842 14.2571 10.9079 14.4488C10.4801 14.6668 9.92002 14.6668 8.79992 14.6668H8.33325V13.5002C8.33325 12.6717 7.66168 12.0002 6.83325 12.0002C6.00482 12.0002 5.33325 12.6717 5.33325 13.5002V14.6668H4.53325C3.41315 14.6668 2.85309 14.6668 2.42527 14.4488C2.04895 14.2571 1.74299 13.9511 1.55124 13.5748C1.33325 13.147 1.33325 12.5869 1.33325 11.4668V10.3335H2.33325C3.25373 10.3335 3.99992 9.5873 3.99992 8.66683C3.99992 7.74635 3.25373 7.00016 2.33325 7.00016H1.33325C1.33325 6.06828 1.33325 5.60234 1.48549 5.2348C1.68848 4.74474 2.07783 4.35539 2.56789 4.1524C2.93543 4.00016 3.40137 4.00016 4.33325 4.00016H4.99992V3.00016Z"
:stroke="color"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_1099_16244">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const {
size = 16,
color = 'currentColor',
class: className
} = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -0,0 +1,29 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 16 16"
fill="none"
:class="iconClass"
>
<path
d="M8.00049 1.3335C8.73661 1.33367 9.33332 1.93038 9.3335 2.6665V2.83447C9.82278 2.96041 10.2851 3.15405 10.7095 3.40479L10.8286 3.28564C11.3493 2.76525 12.1937 2.76519 12.7144 3.28564C13.235 3.80626 13.2348 4.65067 12.7144 5.17139L12.5952 5.29053C12.846 5.71486 13.0396 6.17725 13.1655 6.6665H13.3335C14.0699 6.6665 14.6665 7.26411 14.6665 8.00049C14.6663 8.73672 14.0698 9.3335 13.3335 9.3335H13.1655C13.0396 9.82284 12.846 10.2851 12.5952 10.7095L12.7144 10.8286C13.235 11.3493 13.235 12.1937 12.7144 12.7144C12.1937 13.235 11.3493 13.235 10.8286 12.7144L10.7095 12.5952C10.2851 12.846 9.82284 13.0396 9.3335 13.1655V13.3335C9.3335 14.0698 8.73672 14.6663 8.00049 14.6665C7.26411 14.6665 6.6665 14.0699 6.6665 13.3335V13.1655C6.17725 13.0396 5.71486 12.846 5.29053 12.5952L5.17139 12.7144C4.65067 13.2348 3.80626 13.235 3.28564 12.7144C2.76519 12.1937 2.76525 11.3493 3.28564 10.8286L3.40479 10.7095C3.15405 10.2851 2.96041 9.82278 2.83447 9.3335H2.6665C1.93038 9.33332 1.33367 8.73661 1.3335 8.00049C1.3335 7.26422 1.93027 6.66668 2.6665 6.6665H2.83447C2.96043 6.17722 3.15403 5.71488 3.40479 5.29053L3.28564 5.17139C2.76536 4.65065 2.76508 3.80621 3.28564 3.28564C3.80621 2.76508 4.65065 2.76536 5.17139 3.28564L5.29053 3.40479C5.71488 3.15403 6.17722 2.96043 6.6665 2.83447V2.6665C6.66668 1.93027 7.26422 1.3335 8.00049 1.3335ZM7.3335 8.00049L6.00049 6.6665L4.6665 8.00049L7.3335 10.6665L11.3335 6.6665L10.0005 5.3335L7.3335 8.00049Z"
:fill="color"
/>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: number | string
color?: string
class?: string
}
const { size = 16, color = '#60A5FA', class: className } = defineProps<Props>()
const iconClass = computed(() => className || '')
</script>

View File

@@ -1,132 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import NodePreview from './NodePreview.vue'
describe('NodePreview', () => {
let i18n: ReturnType<typeof createI18n>
let pinia: ReturnType<typeof createPinia>
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})
app.use(PrimeVue)
// Create i18n instance
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
preview: 'Preview'
}
}
}
})
// Create pinia instance
pinia = createPinia()
})
const mockNodeDef: ComfyNodeDefV2 = {
name: 'TestNode',
display_name:
'Test Node With A Very Long Display Name That Should Overflow',
category: 'test',
output_node: false,
inputs: {
test_input: {
name: 'test_input',
type: 'STRING',
tooltip: 'Test input'
}
},
outputs: [],
python_module: 'test_module',
description: 'Test node description'
}
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
return mount(NodePreview, {
global: {
plugins: [PrimeVue, i18n, pinia],
stubs: {
// Stub stores if needed
}
},
props: {
nodeDef
}
})
}
it('renders node preview with correct structure', () => {
const wrapper = mountComponent()
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
expect(wrapper.find('.node_header').exists()).toBe(true)
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies overflow-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('overflow-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
})
it('displays truncated long node names with ellipsis', () => {
const longNameNodeDef: ComfyNodeDefV2 = {
...mockNodeDef,
display_name:
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
}
const wrapper = mountComponent(longNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
// Verify the title attribute contains the full name
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
// Verify overflow handling classes are applied
expect(nodeHeader.classes()).toContain('overflow-ellipsis')
// The actual text content should still be the full name (CSS handles truncation)
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
})
it('handles short node names without issues', () => {
const shortNameNodeDef: ComfyNodeDefV2 = {
...mockNodeDef,
display_name: 'Short'
}
const wrapper = mountComponent(shortNameNodeDef)
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.attributes('title')).toBe('Short')
expect(nodeHeader.text()).toContain('Short')
})
it('applies proper spacing to the dot element', () => {
const wrapper = mountComponent()
const headdot = wrapper.find('.headdot')
expect(headdot.classes()).toContain('pr-3')
})
})

View File

@@ -6,14 +6,13 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div class="_sb_node_preview">
<div class="_sb_table">
<div
class="node_header overflow-ellipsis mr-4"
:title="nodeDef.display_name"
class="node_header"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot pr-3" />
<div class="_sb_dot headdot" />
{{ nodeDef.display_name }}
</div>
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>

View File

@@ -42,6 +42,7 @@
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': sidebarSize === 'small'
}"
@whats-new-dismissed="handleWhatsNewDismissed"
/>
</Teleport>
@@ -57,12 +58,14 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/composables/useConflictDetection'
import { useDialogService } from '@/services/dialogService'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -70,9 +73,21 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const conflictDetection = useConflictDetection()
const conflictAcknowledgment = useConflictAcknowledgment()
const { showNodeConflictDialog } = useDialogService()
const isHelpCenterVisible = ref(false)
// Use conflict acknowledgment state from composable
const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
conflictAcknowledgment
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed(() => {
const releaseRedDot = releaseStore.shouldShowRedDot
return releaseRedDot || shouldShowConflictRedDot.value
})
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
)
@@ -87,6 +102,38 @@ const closeHelpCenter = () => {
isHelpCenterVisible.value = false
}
/**
* Handle What's New popup dismissal
* Check if conflict modal should be shown after ComfyUI update
*/
const handleWhatsNewDismissed = async () => {
try {
// Check if conflict modal should be shown after update
const shouldShow =
await conflictDetection.shouldShowConflictModalAfterUpdate()
if (shouldShow) {
showConflictModal()
}
} catch (error) {
console.error('[HelpCenter] Error checking conflict modal:', error)
}
}
/**
* Show the node conflict dialog with current conflict data
*/
const showConflictModal = () => {
showNodeConflictDialog({
showAfterWhatsNew: true,
dialogComponentProps: {
onClose: () => {
markConflictsAsSeen()
}
}
})
}
// Initialize release store on mount
onMounted(async () => {
// Initialize release store to fetch releases for toast and popup

View File

@@ -1,195 +0,0 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<div class="relative w-full">
<div
class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10"
>
<i class="pi pi-search text-surface-400"></i>
</div>
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-md pl-10 pr-10'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
<div
v-if="searchQuery"
class="absolute inset-y-0 right-0 flex items-center pr-3 z-10"
>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer"
@click="searchQuery = ''"
>
<i class="pi pi-times"></i>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Model Filter Dropdown -->
<div class="relative">
<Button
ref="modelFilterButton"
:label="modelFilterLabel"
icon="pi pi-filter"
outlined
class="rounded-2xl"
@click="toggleModelFilter"
/>
<Popover
ref="modelFilterPopover"
:pt="{
root: { class: 'w-64 max-h-80 overflow-auto' }
}"
>
<div class="p-3">
<div class="font-medium mb-2">
{{ $t('templateWorkflows.modelFilter') }}
</div>
<div class="flex flex-col gap-2">
<div
v-for="model in availableModels"
:key="model"
class="flex items-center"
>
<Checkbox
:model-value="selectedModels.includes(model)"
:binary="true"
@change="toggleModel(model)"
/>
<label class="ml-2 text-sm">{{ model }}</label>
</div>
</div>
</div>
</Popover>
</div>
<!-- Sort Dropdown -->
<Select
v-model="sortBy"
:options="sortOptions"
option-label="label"
option-value="value"
:placeholder="$t('templateWorkflows.sort')"
class="rounded-2xl"
:pt="{
root: { class: 'min-w-32' }
}"
/>
</div>
</div>
<!-- Filter Tags Row -->
<div
v-if="selectedModels.length > 0"
class="flex items-center gap-2 mt-3 overflow-x-auto pb-2"
>
<div class="flex gap-2">
<Tag
v-for="model in selectedModels"
:key="model"
:value="model"
severity="secondary"
>
<template #icon>
<button
type="button"
class="text-surface-400 hover:text-surface-600 bg-transparent border-none p-0 cursor-pointer ml-1"
@click="removeModel(model)"
>
<i class="pi pi-times text-xs"></i>
</button>
</template>
</Tag>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Popover from 'primevue/popover'
import Select from 'primevue/select'
import Tag from 'primevue/tag'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SortOption } from '@/composables/useTemplateFiltering'
const { t } = useI18n()
const { availableModels } = defineProps<{
filteredCount?: number | null
availableModels: string[]
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const selectedModels = defineModel<string[]>('selectedModels', {
default: () => []
})
const sortBy = defineModel<SortOption>('sortBy', { default: 'recommended' })
// emit removed - no longer needed since clearFilters was removed
const modelFilterButton = ref<HTMLElement>()
const modelFilterPopover = ref()
const sortOptions = [
{ label: t('templateWorkflows.sortRecommended'), value: 'recommended' },
{ label: t('templateWorkflows.sortAlphabetical'), value: 'alphabetical' },
{ label: t('templateWorkflows.sortNewest'), value: 'newest' }
]
const modelFilterLabel = computed(() => {
if (selectedModels.value.length === 0) {
return t('templateWorkflows.modelFilter')
} else if (selectedModels.value.length === 1) {
return selectedModels.value[0]
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModels.value.length
})
}
})
const toggleModelFilter = (event: Event) => {
modelFilterPopover.value.toggle(event)
}
const toggleModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
} else {
selectedModels.value.push(model)
}
}
const removeModel = (model: string) => {
const index = selectedModels.value.indexOf(model)
if (index > -1) {
selectedModels.value.splice(index, 1)
}
}
// clearFilters function removed - no longer used since we removed the clear button
</script>

View File

@@ -62,49 +62,14 @@
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3 relative">
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
{{ title }}
</h3>
<!-- Description / Action Buttons Container -->
<div class="relative grow">
<!-- Description Text -->
<p
class="line-clamp-2 text-sm text-muted grow transition-opacity duration-200"
:class="{ 'opacity-0': isHovered }"
:title="description"
>
{{ description }}
</p>
<!-- Action Buttons (visible on hover) -->
<div
v-if="isHovered"
class="absolute inset-0 flex items-center gap-2 transition-opacity duration-200"
>
<Button
v-if="template.tutorialUrl"
size="small"
severity="secondary"
outlined
class="flex-1 rounded-lg"
@click.stop="openTutorial"
>
{{ $t('templateWorkflows.tutorial') }}
</Button>
<Button
size="small"
severity="primary"
:class="template.tutorialUrl ? 'flex-1' : 'w-full'"
class="rounded-lg"
@click.stop="$emit('loadWorkflow', template.name)"
>
{{ $t('templateWorkflows.useTemplate') }}
</Button>
</div>
</div>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
</div>
</div>
</template>
@@ -113,7 +78,6 @@
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import Button from 'primevue/button'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref } from 'vue'
@@ -169,12 +133,6 @@ const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const openTutorial = () => {
if (template.tutorialUrl) {
window.open(template.tutorialUrl, '_blank', 'noopener,noreferrer')
}
}
defineEmits<{
loadWorkflow: [name: string]
}>()

View File

@@ -1,30 +0,0 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
@@ -29,15 +28,6 @@ vi.mock('primevue/selectbutton', () => ({
}
}))
vi.mock('primevue/button', () => ({
default: {
name: 'Button',
template: '<button class="p-button"><slot></slot></button>',
props: ['severity', 'outlined', 'size', 'class'],
emits: ['click']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
@@ -63,63 +53,10 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
}
}))
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: [
'searchQuery',
'filteredCount',
'availableModels',
'selectedModels',
'sortBy'
],
emits: [
'update:searchQuery',
'update:selectedModels',
'update:sortBy',
'clearFilters'
]
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
selectedModels: { value: [] },
selectedSubcategory: { value: null },
sortBy: { value: 'recommended' },
availableSubcategories: { value: [] },
availableModels: { value: [] },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 },
resetFilters: vi.fn()
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
@@ -130,18 +67,6 @@ describe('TemplateWorkflowView', () => {
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
@@ -153,12 +78,7 @@ describe('TemplateWorkflowView', () => {
createTemplate('template-3')
],
loading: null,
availableSubcategories: [],
selectedSubcategory: null,
...props
},
global: {
plugins: [i18n]
}
})
}

View File

@@ -1,57 +1,24 @@
<template>
<DataView
:value="finalDisplayTemplates"
:value="templates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:root="h-full grid grid-rows-[auto_1fr]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<!-- Subcategory Navigation -->
<div
v-if="availableSubcategories.length > 0"
class="mb-4 overflow-x-scroll flex max-w-[1000px]"
<div class="flex justify-between items-center">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<div class="flex gap-2 pb-2">
<Button
:severity="selectedSubcategory === null ? 'primary' : 'secondary'"
:outlined="selectedSubcategory !== null"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', null)"
>
{{ $t('templateWorkflows.allSubcategories') }}
</Button>
<Button
v-for="subcategory in availableSubcategories"
:key="subcategory"
:severity="
selectedSubcategory === subcategory ? 'primary' : 'secondary'
"
:outlined="selectedSubcategory !== subcategory"
size="small"
class="rounded-2xl whitespace-nowrap"
@click="$emit('update:selectedSubcategory', subcategory)"
>
{{ subcategory }}
</Button>
</div>
</div>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
</template>
@@ -66,35 +33,18 @@
</template>
<template #grid="{ items }">
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
</div>
</template>
</DataView>
@@ -102,37 +52,19 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const {
title,
sourceModule,
categoryTitle,
loading,
templates,
availableSubcategories,
selectedSubcategory
} = defineProps<{
defineProps<{
title: string
sourceModule: string
categoryTitle: string
loading: string | null
templates: TemplateInfo[]
availableSubcategories: string[]
selectedSubcategory: string | null
}>()
const layout = useLocalStorage<'grid' | 'list'>(
@@ -140,59 +72,8 @@ const layout = useLocalStorage<'grid' | 'list'>(
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
// Since filtering is now handled at parent level, we just use the templates directly
const displayTemplates = computed(() => templates || [])
// Simplified pagination - only show pagination when we have lots of templates
const shouldUsePagination = computed(() => templates.length > 12)
// Lazy pagination setup using templates directly
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset: resetPagination
} = useLazyPagination(displayTemplates, {
itemsPerPage: 12
})
// Final templates to display with pagination
const finalDisplayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: displayTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates], () => {
resetPagination()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
'update:selectedSubcategory': [value: string | null]
}>()
const onLoadWorkflow = (name: string) => {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col h-[83vh] w-[90vw] relative pb-6 px-8 mx-auto"
class="flex flex-col h-[83vh] w-[90vw] relative pb-6"
data-testid="template-workflows-content"
>
<Button
@@ -12,13 +12,12 @@
@click="toggleSideNav"
/>
<Divider
class="my-0 w-[90vw] -mx-8 relative [&::before]:border-surface-border/70 [&::before]:border-t-2"
class="m-0 [&::before]:border-surface-border/70 [&::before]:border-t-2"
/>
<div class="flex flex-1 relative overflow-hidden">
<aside
v-if="isSideNavOpen"
class="absolute translate-x-0 top-0 left-0 h-full w-60 shadow-md z-5 transition-transform duration-300 ease-in-out"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
@@ -26,64 +25,27 @@
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-subcategory="selectedSubcategory"
:selected-view="selectedView"
@update:selected-subcategory="handleSubcategory"
@update:selected-view="handleViewSelection"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
/>
</aside>
<div
class="flex-1 transition-all duration-300"
:class="{
'pl-60': isSideNavOpen || !isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<div
v-if="
isReady &&
(selectedView === 'all' ||
selectedView === 'recent' ||
selectedSubcategory)
"
class="flex flex-col h-full"
>
<div class="px-8 sm:px-12 py-4 border-b border-surface-border/20">
<TemplateSearchBar
v-model:search-query="searchQuery"
v-model:selected-models="selectedModels"
v-model:sort-by="sortBy"
:filtered-count="filteredCount"
:available-models="availableModels"
@clear-filters="resetFilters"
/>
</div>
<TemplateWorkflowView
class="px-8 sm:px-12 flex-1"
:title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
:source-module="selectedTemplate?.moduleName || 'all'"
:templates="filteredTemplates"
:available-subcategories="availableSubcategories"
:selected-subcategory="filterSubcategory"
:loading="loadingTemplateId"
:category-title="
selectedSubcategory
? selectedSubcategory.label
: selectedView === 'all'
? $t('templateWorkflows.view.allTemplates', 'All Templates')
: selectedTemplate?.title || ''
"
@load-workflow="handleLoadWorkflow"
@update:selected-subcategory="filterSubcategory = $event"
/>
</div>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
</div>
</div>
</div>
@@ -94,15 +56,13 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { watch } from 'vue'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { TemplateSubcategory } from '@/types/workflowTemplateTypes'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const {
isSmallScreen,
@@ -116,78 +76,34 @@ const {
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
// State for subcategory selection
const selectedSubcategory = ref<TemplateSubcategory | null>(null)
// State for view selection (all vs recent)
const selectedView = ref<'all' | 'recent'>('all')
// Template filtering for the top-level search
const templatesRef = computed(() => {
// If a subcategory is selected, use all templates from that subcategory
if (selectedSubcategory.value) {
return selectedSubcategory.value.modules.flatMap(
(module) => module.templates
)
}
// If "All Templates" view is selected and no subcategory, show all templates across all groups
if (selectedView.value === 'all') {
return allTemplateGroups.value.flatMap((group) =>
group.subcategories.flatMap((subcategory) =>
subcategory.modules.flatMap((module) => module.templates)
)
)
}
// Otherwise, use the selected template's templates (for recent view or fallback)
return selectedTemplate.value?.templates || []
})
const {
searchQuery,
selectedModels,
selectedSubcategory: filterSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters
} = useTemplateFiltering(templatesRef)
watch(
isReady,
() => {
if (isReady.value) {
// Start with "All Templates" view by default instead of selecting first subcategory
selectedView.value = 'all'
selectedSubcategory.value = null
selectFirstTemplateCategory()
}
},
{ once: true }
)
const handleSubcategory = (subcategory: TemplateSubcategory) => {
selectedSubcategory.value = subcategory
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
// On small screens, close the sidebar when a subcategory is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
isSideNavOpen.value = false
}
}
}
const handleViewSelection = (view: 'all' | 'recent') => {
selectedView.value = view
// Clear subcategory selection when switching to header views
selectedSubcategory.value = null
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h3 class="px-8 text-base font-medium">
<h3 class="px-4">
<span>{{ $t('templateWorkflows.title') }}</span>
</h3>
</div>

View File

@@ -1,142 +1,50 @@
<template>
<ScrollPanel class="w-60" style="height: calc(83vh - 48px)">
<!-- View Selection Header -->
<div
class="text-left py-3 border-b border-surface-200 dark-theme:border-surface-700"
<ScrollPanel class="w-80" style="height: calc(83vh - 48px)">
<Listbox
:model-value="selectedTab"
:options="tabs"
option-group-label="label"
option-label="localizedTitle"
option-group-children="modules"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-12 py-3 text-lg' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
list-style="max-height:unset"
@update:model-value="handleTabSelection"
>
<Listbox
:model-value="selectedView"
:options="viewOptions"
option-label="label"
option-value="value"
:multiple="false"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
header: { class: 'px-0' },
option: {
class: 'px-0 py-2 text-sm font-medium flex items-center gap-1'
}
}"
@update:model-value="handleViewSelection"
>
<template #option="slotProps">
<div class="flex self-center items-center gap-1 py-1 px-4">
<i :class="slotProps.option.icon"></i>
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
<!-- Template Groups and Subcategories -->
<div class="w-full">
<div v-for="group in props.tabs" :key="group.label" class="mb-2">
<!-- Group Header -->
<div class="text-left py-1">
<h3
class="text-muted text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 px-4"
>
{{ group.label }}
</h3>
<template #optiongroup="slotProps">
<div class="text-left py-3 px-12">
<h2 class="text-lg">
{{ slotProps.option.label }}
</h2>
</div>
<!-- Subcategories as Listbox -->
<Listbox
:model-value="selectedSubcategory"
:options="group.subcategories"
option-label="label"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-0' },
option: { class: 'px-4 py-3 text-sm' }
}"
@update:model-value="handleSubcategorySelection"
>
<template #option="slotProps">
<div class="flex items-center">
<div>{{ slotProps.option.label }}</div>
</div>
</template>
</Listbox>
</div>
</div>
</template>
</Listbox>
</ScrollPanel>
</template>
<script setup lang="ts">
import Listbox from 'primevue/listbox'
import ScrollPanel from 'primevue/scrollpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
TemplateGroup,
TemplateSubcategory
WorkflowTemplates
} from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const props = defineProps<{
defineProps<{
tabs: TemplateGroup[]
selectedSubcategory: TemplateSubcategory | null
selectedView: 'all' | 'recent' | null
selectedTab: WorkflowTemplates | null
}>()
const emit = defineEmits<{
(e: 'update:selectedSubcategory', subcategory: TemplateSubcategory): void
(e: 'update:selectedView', view: 'all' | 'recent'): void
(e: 'update:selectedTab', tab: WorkflowTemplates): void
}>()
const viewOptions = computed(() => {
// Create a comprehensive "All Templates" subcategory that aggregates all modules
const allTemplatesSubcategory = {
label: t('templateWorkflows.view.allTemplates', 'All Templates'),
modules: props.tabs.flatMap((group: TemplateGroup) =>
group.subcategories.flatMap(
(subcategory: TemplateSubcategory) => subcategory.modules
)
)
}
const recentItemsSubcategory = {
label: t('templateWorkflows.view.recentItems', 'Recent Items'),
modules: [] // Could be populated with recent templates if needed
}
return [
{
...allTemplatesSubcategory,
icon: 'pi pi-list mr-2'
},
{
...recentItemsSubcategory,
icon: 'pi pi-clock mr-2'
}
]
})
const handleSubcategorySelection = (subcategory: TemplateSubcategory) => {
emit('update:selectedSubcategory', subcategory)
}
const handleViewSelection = (subcategory: TemplateSubcategory | null) => {
// Prevent deselection - always keep a view selected
if (subcategory !== null) {
// Emit as subcategory selection since these now have comprehensive module data
emit('update:selectedSubcategory', subcategory)
// Also emit view selection for backward compatibility
const viewValue = subcategory.label.includes('All Templates')
? 'all'
: 'recent'
emit('update:selectedView', viewValue)
}
// If subcategory is null, we don't emit anything, keeping the current selection
const handleTabSelection = (tab: WorkflowTemplates) => {
emit('update:selectedTab', tab)
}
</script>
<style scoped>
:deep(.p-listbox-header) {
padding: 0;
}
</style>

View File

@@ -12,15 +12,6 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
@@ -44,24 +35,23 @@ describe('CompareSliderThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageStyle = overlayLazyImage.props('imageStyle')
expect(imageStyle.clipPath).toContain('inset')
const overlay = wrapper.findAll('img')[1]
expect(overlay.attributes('style')).toContain('clip-path')
})
it('renders slider divider', () => {

View File

@@ -1,24 +1,24 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<LazyImage
<img
:src="baseImageSrc"
:alt="alt"
:image-class="
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<div ref="containerRef" class="absolute inset-0">
<LazyImage
<img
:src="overlayImageSrc"
:alt="alt"
:image-class="
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
:image-style="{
:style="{
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
}"
/>
@@ -36,7 +36,6 @@
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50

View File

@@ -11,15 +11,6 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
@@ -34,9 +25,9 @@ describe('DefaultThumbnail', () => {
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('src')).toBe('/test-image.jpg')
expect(lazyImage.props('alt')).toBe('Test Image')
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/test-image.jpg')
expect(img.attributes('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
@@ -44,43 +35,35 @@ describe('DefaultThumbnail', () => {
isHovered: true,
hoverZoom: 10
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
const img = wrapper.find('img')
expect(img.attributes('style')).toContain('scale(1.1)')
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toBeUndefined()
const img = wrapper.find('img')
expect(img.attributes('style')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('w-full')
expect(classString).toContain('h-full')
expect(classString).toContain('object-cover')
const img = wrapper.find('img')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('max-w-full')
expect(classString).toContain('object-contain')
const img = wrapper.find('img')
expect(img.classes()).toContain('max-w-full')
expect(img.classes()).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
@@ -88,12 +71,8 @@ describe('DefaultThumbnail', () => {
src: '/test-video.webp',
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('object-cover')
const img = wrapper.find('img')
expect(img.classes()).toContain('object-cover')
})
it('image is not draggable', () => {
@@ -104,15 +83,11 @@ describe('DefaultThumbnail', () => {
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transform-gpu')
expect(classString).toContain('transition-transform')
expect(classString).toContain('duration-300')
expect(classString).toContain('ease-out')
const img = wrapper.find('img')
expect(img.classes()).toContain('transform-gpu')
expect(img.classes()).toContain('transition-transform')
expect(img.classes()).toContain('duration-300')
expect(img.classes()).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {

View File

@@ -1,23 +1,25 @@
<template>
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
<LazyImage
:src="src"
:alt="alt"
:image-class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:image-style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
<div class="overflow-hidden w-full h-full flex items-center justify-center">
<img
:src="src"
:alt="alt"
draggable="false"
:class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
</div>
</BaseThumbnail>
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { src, isVideo } = defineProps<{

View File

@@ -11,15 +11,6 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('HoverDissolveThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(HoverDissolveThumbnail, {
@@ -36,39 +27,31 @@ describe('HoverDissolveThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
})
it('makes overlay image visible when hovered', () => {
const wrapper = mountThumbnail({ isHovered: true })
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-100')
expect(classString).not.toContain('opacity-0')
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-100')
expect(overlayImage.classes()).not.toContain('opacity-0')
})
it('makes overlay image hidden when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-0')
expect(classString).not.toContain('opacity-100')
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-0')
expect(overlayImage.classes()).not.toContain('opacity-100')
})
it('passes isHovered prop to BaseThumbnail', () => {
@@ -79,33 +62,21 @@ describe('HoverDissolveThumbnail', () => {
it('applies transition classes to overlay image', () => {
const wrapper = mountThumbnail()
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transition-opacity')
expect(classString).toContain('duration-300')
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('transition-opacity')
expect(overlayImage.classes()).toContain('duration-300')
})
it('applies correct positioning to both images', () => {
const wrapper = mountThumbnail()
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
const images = wrapper.findAll('img')
// Check base image
const baseImageClass = lazyImages[0].props('imageClass')
const baseClassString = Array.isArray(baseImageClass)
? baseImageClass.join(' ')
: baseImageClass
expect(baseClassString).toContain('absolute')
expect(baseClassString).toContain('inset-0')
expect(images[0].classes()).toContain('absolute')
expect(images[0].classes()).toContain('inset-0')
// Check overlay image
const overlayImageClass = lazyImages[1].props('imageClass')
const overlayClassString = Array.isArray(overlayImageClass)
? overlayImageClass.join(' ')
: overlayImageClass
expect(overlayClassString).toContain('absolute')
expect(overlayClassString).toContain('inset-0')
expect(images[1].classes()).toContain('absolute')
expect(images[1].classes()).toContain('inset-0')
})
})

View File

@@ -1,23 +1,37 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<div class="relative w-full h-full">
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
<LazyImage
<img
:src="baseImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<img
:src="overlayImageSrc"
:alt="alt"
:image-class="overlayImageClass"
draggable="false"
class="absolute inset-0 transition-opacity duration-300"
:class="[
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain',
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
]"
/>
</div>
</BaseThumbnail>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
baseImageSrc: string
overlayImageSrc: string
alt: string
@@ -30,17 +44,4 @@ const isVideoType =
baseImageSrc?.toLowerCase().endsWith('.webp') ||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
false
const baseImageClass = computed(() => {
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
})
const overlayImageClass = computed(() => {
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
const sizeClasses = isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
})
</script>

View File

@@ -1,71 +0,0 @@
import { Positionable, Reroute } from '@comfyorg/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
/**
* Composable for handling selected LiteGraph items filtering and operations.
* This provides utilities for working with selected items on the canvas,
* including filtering out items that should not be included in selection operations.
*/
export function useSelectedLiteGraphItems() {
const canvasStore = useCanvasStore()
/**
* Items that should not show in the selection overlay are ignored.
* @param item - The item to check.
* @returns True if the item should be ignored, false otherwise.
*/
const isIgnoredItem = (item: Positionable): boolean => {
return item instanceof Reroute
}
/**
* Filter out items that should not show in the selection overlay.
* @param items - The Set of items to filter.
* @returns The filtered Set of items.
*/
const filterSelectableItems = (
items: Set<Positionable>
): Set<Positionable> => {
const result = new Set<Positionable>()
for (const item of items) {
if (!isIgnoredItem(item)) {
result.add(item)
}
}
return result
}
/**
* Get the filtered selected items from the canvas.
* @returns The filtered Set of selected items.
*/
const getSelectableItems = (): Set<Positionable> => {
const { selectedItems } = canvasStore.getCanvas()
return filterSelectableItems(selectedItems)
}
/**
* Check if there are any selectable items.
* @returns True if there are selectable items, false otherwise.
*/
const hasSelectableItems = (): boolean => {
return getSelectableItems().size > 0
}
/**
* Check if there are multiple selectable items.
* @returns True if there are multiple selectable items, false otherwise.
*/
const hasMultipleSelectableItems = (): boolean => {
return getSelectableItems().size > 1
}
return {
isIgnoredItem,
filterSelectableItems,
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
}
}

View File

@@ -20,7 +20,10 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
await comfyManagerStore.refreshInstalledList()
// Only refresh if store doesn't have data yet
if (comfyManagerStore.installedPacksIds.size === 0) {
await comfyManagerStore.refreshInstalledList()
}
await startFetch()
}
@@ -33,11 +36,29 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
cleanup()
})
// Create a computed property that provides installed pack info with versions
const installedPacksWithVersions = computed(() => {
const result: Array<{ id: string; version: string }> = []
for (const pack of Object.values(comfyManagerStore.installedPacks)) {
const id = pack.cnr_id || pack.aux_id
if (id) {
result.push({
id,
version: pack.ver
})
}
}
return result
})
return {
error,
isLoading,
isReady,
installedPacks: nodePacks,
installedPacksWithVersions,
startFetchInstalled,
filterInstalledPack
}

View File

@@ -0,0 +1,65 @@
import { computed, onMounted } from 'vue'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
/**
* Composable to find NodePacks that have updates available
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches installed pack data when initialized
*/
export const useUpdateAvailableNodes = () => {
const comfyManagerStore = useComfyManagerStore()
const { installedPacks, isLoading, error, startFetchInstalled } =
useInstalledPacks()
// Check if a pack has updates available (same logic as usePackUpdateStatus)
const isOutdatedPack = (pack: components['schemas']['Node']) => {
const isInstalled = comfyManagerStore.isPackInstalled(pack?.id)
if (!isInstalled) return false
const installedVersion = comfyManagerStore.getInstalledPackVersion(
pack.id ?? ''
)
const latestVersion = pack.latest_version?.version
const isNightlyPack = !!installedVersion && !isSemVer(installedVersion)
if (isNightlyPack || !latestVersion) {
return false
}
return compareVersions(latestVersion, installedVersion) > 0
}
// Same filtering logic as ManagerDialogContent.vue
const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
packs.filter(isOutdatedPack)
// Filter only outdated packs from installed packs
const updateAvailableNodePacks = computed(() => {
if (!installedPacks.value.length) return []
return filterOutdatedPacks(installedPacks.value)
})
// Check if there are any outdated packs
const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0
})
// Automatically fetch installed pack data when composable is used
onMounted(async () => {
if (!installedPacks.value.length && !isLoading.value) {
await startFetchInstalled()
}
})
return {
updateAvailableNodePacks,
hasUpdateAvailable,
isLoading,
error
}
}

View File

@@ -7,7 +7,7 @@ import { app } from '@/scripts/app'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import { UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
@@ -66,8 +66,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
return {
id: CORE_NODES_PACK_NAME,
version:
systemStatsStore.systemStats?.system?.comfyui_version ??
SelectedVersion.NIGHTLY
systemStatsStore.systemStats?.system?.comfyui_version ?? 'nightly'
}
}
@@ -77,7 +76,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
if (pack) {
return {
id: pack.id,
version: pack.latest_version?.version ?? SelectedVersion.NIGHTLY
version: pack.latest_version?.version ?? 'nightly'
}
}

View File

@@ -0,0 +1,103 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
/**
* LocalStorage keys for conflict acknowledgment tracking
*/
const STORAGE_KEYS = {
CONFLICT_MODAL_DISMISSED: 'Comfy.ConflictModalDismissed',
CONFLICT_RED_DOT_DISMISSED: 'Comfy.ConflictRedDotDismissed',
CONFLICT_WARNING_BANNER_DISMISSED: 'Comfy.ConflictWarningBannerDismissed'
} as const
/**
* Interface for conflict acknowledgment state
*/
interface ConflictAcknowledgmentState {
modal_dismissed: boolean
red_dot_dismissed: boolean
warning_banner_dismissed: boolean
}
/**
* Composable for managing conflict acknowledgment state in localStorage
*
* This handles:
* - Tracking whether conflict modal has been dismissed
* - Tracking whether red dot notification has been cleared
* - Managing per-package conflict acknowledgments
* - Detecting ComfyUI version changes to reset acknowledgment state
*/
export function useConflictAcknowledgment() {
const conflictDetectionStore = useConflictDetectionStore()
// Reactive state using VueUse's useStorage for automatic persistence
const modalDismissed = useStorage(
STORAGE_KEYS.CONFLICT_MODAL_DISMISSED,
false
)
const redDotDismissed = useStorage(
STORAGE_KEYS.CONFLICT_RED_DOT_DISMISSED,
false
)
const warningBannerDismissed = useStorage(
STORAGE_KEYS.CONFLICT_WARNING_BANNER_DISMISSED,
false
)
// Create computed state object for backward compatibility
const state = computed<ConflictAcknowledgmentState>(() => ({
modal_dismissed: modalDismissed.value,
red_dot_dismissed: redDotDismissed.value,
warning_banner_dismissed: warningBannerDismissed.value
}))
/**
* Mark red dot notification as dismissed
*/
function dismissRedDotNotification(): void {
redDotDismissed.value = true
}
/**
* Mark manager warning banner as dismissed
*/
function dismissWarningBanner(): void {
warningBannerDismissed.value = true
redDotDismissed.value = true
}
/**
* Mark conflicts as seen (unified function for help center and manager)
*/
function markConflictsAsSeen(): void {
redDotDismissed.value = true
modalDismissed.value = true
warningBannerDismissed.value = true
}
const hasConflicts = computed(() => conflictDetectionStore.hasConflicts)
const shouldShowConflictModal = computed(() => !modalDismissed.value)
const shouldShowRedDot = computed(() => {
if (!hasConflicts.value) return false
return !redDotDismissed.value
})
const shouldShowManagerBanner = computed(() => {
return hasConflicts.value && !warningBannerDismissed.value
})
return {
// State
acknowledgmentState: state,
shouldShowConflictModal,
shouldShowRedDot,
shouldShowManagerBanner,
// Methods
dismissRedDotNotification,
dismissWarningBanner,
markConflictsAsSeen
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,11 @@ import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { addFluxKontextGroupNode } from '@/scripts/fluxKontextEditNode'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { type ComfyCommand, useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
@@ -29,11 +30,8 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes
} from '@/utils/graphTraversalUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -367,10 +365,10 @@ export function useCoreCommands(): ComfyCommand[] {
versionAdded: '1.19.6',
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
const selectedNodes = getSelectedNodes()
const selectedOutputNodes = filterOutputNodes(selectedNodes)
if (selectedOutputNodes.length === 0) {
const queueNodeIds = getSelectedNodes()
.filter((node) => node.constructor.nodeData?.output_node)
.map((node) => node.id)
if (queueNodeIds.length === 0) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
@@ -379,11 +377,7 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
// Get execution IDs for all selected output nodes and their descendants
const executionIds =
getExecutionIdsForSelectedNodes(selectedOutputNodes)
await app.queuePrompt(0, batchCount, executionIds)
await app.queuePrompt(0, batchCount, queueNodeIds)
}
},
{
@@ -691,12 +685,53 @@ export function useCoreCommands(): ComfyCommand[] {
}
},
{
id: 'Comfy.Manager.CustomNodesManager',
id: 'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
label: 'Show the Custom Nodes Manager',
versionAdded: '1.12.10',
function: async () => {
const { is_legacy_manager_ui } =
(await useComfyManagerService().isLegacyManagerUI()) ?? {}
if (is_legacy_manager_ui === true) {
try {
await useCommandStore().execute(
'Comfy.Manager.Menu.ToggleVisibility' // This command is registered by legacy manager FE extension
)
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.toggleManagerDialog()
}
} else {
dialogService.toggleManagerDialog()
}
}
},
{
id: 'Comfy.Manager.ShowUpdateAvailablePacks',
icon: 'pi pi-sync',
label: 'Check for Updates',
versionAdded: '1.17.0',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog({
initialTab: ManagerTab.UpdateAvailable
})
}
},
{
id: 'Comfy.Manager.ShowMissingPacks',
icon: 'pi pi-exclamation-circle',
label: 'Install Missing',
versionAdded: '1.17.0',
function: () => {
dialogService.showManagerDialog({
initialTab: ManagerTab.Missing
})
}
},
{
@@ -785,9 +820,88 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
const { node } = res
canvas.select(node)
}
},
{
id: 'Comfy.Manager.CustomNodesManager.ShowLegacyCustomNodesMenu',
icon: 'pi pi-bars',
label: 'Custom Nodes (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute(
'Comfy.Manager.CustomNodesManager.ToggleVisibility'
)
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Manager.ShowLegacyManagerMenu',
icon: 'mdi mdi-puzzle',
label: 'Manager Menu (Legacy)',
versionAdded: '1.16.4',
function: async () => {
try {
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
} catch (error) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
}
},
{
id: 'Comfy.Memory.UnloadModels',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: false })
}
},
{
id: 'Comfy.Memory.UnloadModelsAndExecutionCache',
icon: 'mdi mdi-vacuum-outline',
label: 'Unload Models and Execution Cache',
versionAdded: '1.16.4',
function: async () => {
if (!useSettingStore().get('Comfy.Memory.AllowManualUnload')) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
}),
life: 3000
})
return
}
await api.freeMemory({ freeExecutionCache: true })
}
}
]

View File

@@ -0,0 +1,85 @@
import { type ComputedRef, computed, unref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/stores/conflictDetectionStore'
import type { ConflictDetail } from '@/types/conflictDetectionTypes'
/**
* Extracting import failed conflicts from conflict list
*/
function extractImportFailedConflicts(conflicts?: ConflictDetail[] | null) {
if (!conflicts) return null
const importFailedConflicts = conflicts.filter(
(item): item is ConflictDetail => item.type === 'import_failed'
)
return importFailedConflicts.length > 0 ? importFailedConflicts : null
}
/**
* Creating import failed dialog
*/
function createImportFailedDialog() {
const { t } = useI18n()
const { showErrorDialog } = useDialogService()
return (importFailedInfo: ConflictDetail[] | null) => {
if (importFailedInfo) {
const errorMessage =
importFailedInfo
.map((conflict) => conflict.required_value)
.filter(Boolean)
.join('\n') || t('manager.importFailedGenericError')
const error = new Error(errorMessage)
showErrorDialog(error, {
title: t('manager.failedToInstall'),
reportType: 'importFailedError'
})
}
}
}
/**
* Composable for detecting and handling import failed conflicts
* @param packageId - Package ID string or computed ref
* @returns Object with import failed detection and dialog handler
*/
export function useImportFailedDetection(
packageId?: string | ComputedRef<string> | null
) {
const { isPackInstalled } = useComfyManagerStore()
const { getConflictsForPackageByID } = useConflictDetectionStore()
const isInstalled = computed(() =>
packageId ? isPackInstalled(unref(packageId)) : false
)
const conflicts = computed(() => {
const currentPackageId = unref(packageId)
if (!currentPackageId || !isInstalled.value) return null
return getConflictsForPackageByID(currentPackageId) || null
})
const importFailedInfo = computed(() => {
return extractImportFailedConflicts(conflicts.value?.conflicts)
})
const importFailed = computed(() => {
return importFailedInfo.value !== null
})
const showImportFailedDialog = createImportFailedDialog()
return {
importFailedInfo,
importFailed,
showImportFailedDialog: () =>
showImportFailedDialog(importFailedInfo.value),
isInstalled
}
}

View File

@@ -1,60 +0,0 @@
import { type Ref, onBeforeUnmount, ref, watch } from 'vue'
export interface UseIntersectionObserverOptions
extends IntersectionObserverInit {
immediate?: boolean
}
export function useIntersectionObserver(
target: Ref<Element | null>,
callback: IntersectionObserverCallback,
options: UseIntersectionObserverOptions = {}
) {
const { immediate = true, ...observerOptions } = options
const isSupported =
typeof window !== 'undefined' && 'IntersectionObserver' in window
const isIntersecting = ref(false)
let observer: IntersectionObserver | null = null
const cleanup = () => {
if (observer) {
observer.disconnect()
observer = null
}
}
const observe = () => {
cleanup()
if (!isSupported || !target.value) return
observer = new IntersectionObserver((entries) => {
isIntersecting.value = entries.some((entry) => entry.isIntersecting)
callback(entries, observer!)
}, observerOptions)
observer.observe(target.value)
}
const unobserve = () => {
if (observer && target.value) {
observer.unobserve(target.value)
}
}
if (immediate) {
watch(target, observe, { immediate: true, flush: 'post' })
}
onBeforeUnmount(cleanup)
return {
isSupported,
isIntersecting,
observe,
unobserve,
cleanup
}
}

View File

@@ -1,107 +0,0 @@
import { type Ref, computed, ref, shallowRef, watch } from 'vue'
export interface LazyPaginationOptions {
itemsPerPage?: number
initialPage?: number
}
export function useLazyPagination<T>(
items: Ref<T[]> | T[],
options: LazyPaginationOptions = {}
) {
const { itemsPerPage = 12, initialPage = 1 } = options
const currentPage = ref(initialPage)
const isLoading = ref(false)
const loadedPages = shallowRef(new Set<number>([]))
// Get reactive items array
const itemsArray = computed(() => {
const itemData = 'value' in items ? items.value : items
return Array.isArray(itemData) ? itemData : []
})
// Simulate pagination by slicing the items
const paginatedItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return []
}
const loadedPageNumbers = Array.from(loadedPages.value).sort(
(a, b) => a - b
)
const maxLoadedPage = Math.max(...loadedPageNumbers, 0)
const endIndex = maxLoadedPage * itemsPerPage
return itemData.slice(0, endIndex)
})
const hasMoreItems = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return false
}
const loadedPagesArray = Array.from(loadedPages.value)
const maxLoadedPage = Math.max(...loadedPagesArray, 0)
return maxLoadedPage * itemsPerPage < itemData.length
})
const totalPages = computed(() => {
const itemData = itemsArray.value
if (itemData.length === 0) {
return 0
}
return Math.ceil(itemData.length / itemsPerPage)
})
const loadNextPage = async () => {
if (isLoading.value || !hasMoreItems.value) return
isLoading.value = true
const loadedPagesArray = Array.from(loadedPages.value)
const nextPage = Math.max(...loadedPagesArray, 0) + 1
// Simulate network delay
// await new Promise((resolve) => setTimeout(resolve, 5000))
const newLoadedPages = new Set(loadedPages.value)
newLoadedPages.add(nextPage)
loadedPages.value = newLoadedPages
currentPage.value = nextPage
isLoading.value = false
}
// Initialize with first page
watch(
() => itemsArray.value.length,
(length) => {
if (length > 0 && loadedPages.value.size === 0) {
loadedPages.value = new Set([1])
}
},
{ immediate: true }
)
const reset = () => {
currentPage.value = initialPage
loadedPages.value = new Set([])
isLoading.value = false
// Immediately load first page if we have items
const itemData = itemsArray.value
if (itemData.length > 0) {
loadedPages.value = new Set([1])
}
}
return {
paginatedItems,
isLoading,
hasMoreItems,
currentPage,
totalPages,
loadNextPage,
reset
}
}

View File

@@ -1,101 +1,153 @@
import { useEventListener, whenever } from '@vueuse/core'
import { computed, readonly, ref } from 'vue'
import { mapKeys, pickBy } from 'lodash'
import { Ref, computed, ref } from 'vue'
import { api } from '@/scripts/api'
import { ManagerWsQueueStatus } from '@/types/comfyManagerTypes'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes'
type QueuedTask<T> = {
task: () => Promise<T>
onComplete?: () => void
}
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
type QueueTaskItem = components['schemas']['QueueTaskItem']
type HistoryTaskItem = components['schemas']['TaskHistoryItem']
type Task = QueueTaskItem | HistoryTaskItem
const MANAGER_WS_MSG_TYPE = 'cm-queue-status'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
export const useManagerQueue = () => {
const clientQueueItems = ref<QueuedTask<unknown>[]>([])
const clientQueueLength = computed(() => clientQueueItems.value.length)
const onCompletedQueue = ref<((() => void) | undefined)[]>([])
const onCompleteWaitingCount = ref(0)
const uncompletedCount = computed(
() => clientQueueLength.value + onCompleteWaitingCount.value
export const useManagerQueue = (
taskHistory: Ref<ManagerTaskHistory>,
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
const isProcessing = ref(false)
// Computed values
const currentQueueLength = computed(
() =>
taskQueue.value.running_queue.length +
taskQueue.value.pending_queue.length
)
const serverQueueStatus = ref<ManagerWsQueueStatus>(ManagerWsQueueStatus.DONE)
const isServerIdle = computed(
() => serverQueueStatus.value === ManagerWsQueueStatus.DONE
)
/**
* Update the processing state based on the current queue length.
* If the queue is empty, or all tasks in the queue are associated
* with different clients, then this client is not processing any tasks.
*/
const updateProcessingState = (): void => {
isProcessing.value = currentQueueLength.value > 0
}
const allTasksDone = computed(
() => isServerIdle.value && clientQueueLength.value === 0
)
const nextTaskReady = computed(
() => isServerIdle.value && clientQueueLength.value > 0
)
const allTasksDone = computed(() => currentQueueLength.value === 0)
const historyCount = computed(() => Object.keys(taskHistory.value).length)
const cleanupListener = useEventListener(
api,
MANAGER_WS_MSG_TYPE,
(event: CustomEvent<{ status: ManagerWsQueueStatus }>) => {
if (event?.type === MANAGER_WS_MSG_TYPE && event.detail?.status) {
serverQueueStatus.value = event.detail.status
/**
* Check if a task is associated with this client.
* Task can be from running queue, pending queue, or history.
* @param task - The task to check
* @returns True if the task belongs to this client
*/
const isTaskFromThisClient = (task: Task): boolean =>
task.client_id === app.api.clientId
/**
* Filter queue tasks by client id.
* Ensures that only tasks associated with this client are processed and
* added to client state.
* @param tasks - Array of queue tasks to filter
* @returns Filtered array containing only tasks from this client
*/
const filterQueueByClientId = (tasks: QueueTaskItem[]): QueueTaskItem[] =>
tasks.filter(isTaskFromThisClient)
/**
* Filter history tasks by client id using lodash pickBy for optimal performance.
* Returns a new object containing only tasks associated with this client.
* @param history - The history object to filter
* @returns Filtered history object containing only tasks from this client
*/
const filterHistoryByClientId = (history: ManagerTaskHistory) =>
pickBy(history, isTaskFromThisClient)
/**
* Update task queue and history state with filtered data from server.
* Ensures only tasks from this client are stored in local state.
* @param state - The task state message from the server
*/
const updateTaskState = (state: ManagerTaskQueue) => {
taskQueue.value.running_queue = filterQueueByClientId(state.running_queue)
taskQueue.value.pending_queue = filterQueueByClientId(state.pending_queue)
taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) {
// The keys are 'cleaned' by stripping the version suffix.
// The pack object itself (the value) still contains the version info.
const packsWithCleanedKeys = mapKeys(
state.installed_packs,
(_value, key) => {
return key.split('@')[0]
}
)
installedPacks.value = packsWithCleanedKeys
}
updateProcessingState()
}
// WebSocket event listener for task done
const cleanupTaskDoneListener = useEventListener(
app.api,
MANAGER_WS_TASK_DONE_NAME,
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
)
const startNextTask = () => {
const nextTask = clientQueueItems.value.shift()
if (!nextTask) return
const { task, onComplete } = nextTask
if (onComplete) {
// Set the task's onComplete to be executed the next time the server is idle
onCompletedQueue.value.push(onComplete)
onCompleteWaitingCount.value++
}
task().catch((e) => {
const message = `Error enqueuing task for ComfyUI Manager: ${e}`
console.error(message)
})
}
const enqueueTask = <T>(task: QueuedTask<T>): void => {
clientQueueItems.value.push(task)
}
const clearQueue = () => {
clientQueueItems.value = []
onCompletedQueue.value = []
onCompleteWaitingCount.value = 0
}
const cleanup = () => {
clearQueue()
cleanupListener()
}
whenever(nextTaskReady, startNextTask)
whenever(isServerIdle, () => {
if (onCompletedQueue.value?.length) {
while (
onCompleteWaitingCount.value > 0 &&
onCompletedQueue.value.length > 0
) {
const onComplete = onCompletedQueue.value.shift()
onComplete?.()
onCompleteWaitingCount.value--
// WebSocket event listener for task started
const cleanupTaskStartedListener = useEventListener(
app.api,
MANAGER_WS_TASK_STARTED_NAME,
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
const { state } = event.detail
updateTaskState(state)
}
}
})
)
whenever(currentQueueLength, () => showManagerProgressDialog())
const stopListening = () => {
cleanupTaskDoneListener()
cleanupTaskStartedListener()
}
return {
allTasksDone,
statusMessage: readonly(serverQueueStatus),
queueLength: clientQueueLength,
uncompletedCount,
// Queue state (read-only from server)
taskHistory,
taskQueue,
maxHistoryItems,
isLoading,
enqueueTask,
clearQueue,
cleanup
// Computed state
allTasksDone,
isProcessing,
queueLength: currentQueueLength,
historyCount,
// Actions
stopListening
}
}

View File

@@ -5,11 +5,8 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -20,8 +17,6 @@ interface GraphCallbacks {
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const workflowStore = useWorkflowStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
@@ -57,18 +52,12 @@ export function useMinimap() {
const width = 250
const height = 200
// Theme-aware colors for canvas drawing
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const linkColor = computed(
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const nodeColor = '#0B8CE999'
const linkColor = '#F99614'
const slotColor = '#F99614'
const viewportColor = '#FFF'
const backgroundColor = '#15161C'
const borderColor = '#333'
const containerRect = ref({
left: 0,
@@ -108,36 +97,13 @@ export function useMinimap() {
}
const canvas = computed(() => canvasStore.canvas)
const graph = ref(app.canvas?.graph)
// Update graph ref when subgraph context changes
watch(
() => workflowStore.activeSubgraph,
() => {
graph.value = app.canvas?.graph
// Force viewport update when switching subgraphs
if (initialized.value && visible.value) {
updateViewport()
}
}
)
// Update viewport when switching workflows
watch(
() => workflowStore.activeWorkflow,
() => {
// Force viewport update when switching workflows
if (initialized.value && visible.value) {
updateViewport()
}
}
)
const graph = computed(() => canvas.value?.graph)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
backgroundColor: backgroundColor,
border: `1px solid ${borderColor}`,
borderRadius: '8px'
}))
@@ -145,8 +111,8 @@ export function useMinimap() {
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
height: `${viewportTransform.value.height}px`,
border: `2px solid ${isLightTheme.value ? '#E0E0E0' : '#FFF'}`,
backgroundColor: `#FFF33`,
border: `2px solid ${viewportColor}`,
backgroundColor: `${viewportColor}33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
@@ -155,7 +121,7 @@ export function useMinimap() {
const calculateGraphBounds = () => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) {
if (!g?._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
@@ -229,7 +195,7 @@ export function useMinimap() {
const h = node.size[1] * scale.value
// Render solid node blocks
ctx.fillStyle = nodeColor.value
ctx.fillStyle = nodeColor
ctx.fillRect(x, y, w, h)
}
}
@@ -242,7 +208,7 @@ export function useMinimap() {
const g = graph.value
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.strokeStyle = linkColor
ctx.lineWidth = 1.4
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
@@ -291,7 +257,7 @@ export function useMinimap() {
}
// Render connection slots on top
ctx.fillStyle = slotColor.value
ctx.fillStyle = slotColor
for (const conn of connections) {
// Output slot
ctx.beginPath()
@@ -306,14 +272,13 @@ export function useMinimap() {
}
const renderMinimap = () => {
const g = graph.value
if (!canvasRef.value || !g) return
if (!canvasRef.value || !graph.value) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!g._nodes || g._nodes.length === 0) {
if (!graph.value._nodes || graph.value._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}

View File

@@ -1,24 +1,34 @@
import { useEventListener } from '@vueuse/core'
import { onUnmounted, ref } from 'vue'
import { ref } from 'vue'
import { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { components } from '@/types/generatedManagerTypes'
const LOGS_MESSAGE_TYPE = 'logs'
const MANAGER_WS_TASK_DONE_NAME = 'cm-task-completed'
const MANAGER_WS_TASK_STARTED_NAME = 'cm-task-started'
type ManagerWsTaskDoneMsg = components['schemas']['MessageTaskDone']
type ManagerWsTaskStartedMsg = components['schemas']['MessageTaskStarted']
interface UseServerLogsOptions {
ui_id: string
immediate?: boolean
messageFilter?: (message: string) => boolean
}
export const useServerLogs = (options: UseServerLogsOptions = {}) => {
export const useServerLogs = (options: UseServerLogsOptions) => {
const {
immediate = false,
messageFilter = (msg: string) => Boolean(msg.trim())
} = options
const logs = ref<string[]>([])
let stop: ReturnType<typeof useEventListener> | null = null
const isTaskStarted = ref(false)
let stopLogs: ReturnType<typeof useEventListener> | null = null
let stopTaskDone: ReturnType<typeof useEventListener> | null = null
let stopTaskStarted: ReturnType<typeof useEventListener> | null = null
const isValidLogEvent = (event: CustomEvent<LogsWsMessage>) =>
event?.type === LOGS_MESSAGE_TYPE && event.detail?.entries?.length > 0
@@ -27,34 +37,81 @@ export const useServerLogs = (options: UseServerLogsOptions = {}) => {
event.detail.entries.map((e) => e.m).filter(messageFilter)
const handleLogMessage = (event: CustomEvent<LogsWsMessage>) => {
// Only capture logs if this task has started
if (!isTaskStarted.value) return
if (isValidLogEvent(event)) {
logs.value.push(...parseLogMessage(event))
const messages = parseLogMessage(event)
if (messages.length > 0) {
logs.value.push(...messages)
}
}
}
const start = async () => {
const handleTaskStarted = (event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME) {
// Check if this is our task starting
const isOurTask = event.detail.ui_id === options.ui_id
if (isOurTask) {
isTaskStarted.value = true
void stopTaskStarted?.()
}
}
}
const handleTaskDone = (event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME) {
const { state } = event.detail
// Check if our task is now in the history (completed)
const isOurTaskDone = state.history[options.ui_id]
if (isOurTaskDone) {
isTaskStarted.value = false
void stopListening()
}
}
}
const startListening = async () => {
await api.subscribeLogs(true)
stop = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopLogs = useEventListener(api, LOGS_MESSAGE_TYPE, handleLogMessage)
stopTaskStarted = useEventListener(
api,
MANAGER_WS_TASK_STARTED_NAME,
handleTaskStarted
)
stopTaskDone = useEventListener(
api,
MANAGER_WS_TASK_DONE_NAME,
handleTaskDone
)
}
const stopListening = async () => {
stop?.()
stop = null
await api.subscribeLogs(false)
stopLogs?.()
stopTaskStarted?.()
stopTaskDone?.()
stopLogs = null
stopTaskStarted = null
stopTaskDone = null
// TODO: move subscribe/unsubscribe logs to useManagerQueue. Subscribe when task starts if not already subscribed.
// Unsubscribe ONLY when there are no tasks running or queued up and the only remaining task finishes.
// await api.subscribeLogs(false)
}
if (immediate) {
void start()
void startListening()
}
onUnmounted(async () => {
const cleanup = async () => {
await stopListening()
logs.value = []
})
isTaskStarted.value = false
}
return {
logs,
startListening: start,
stopListening
startListening,
stopListening,
cleanup
}
}

View File

@@ -1,134 +0,0 @@
import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
export type SortOption = 'recommended' | 'alphabetical' | 'newest'
export interface TemplateFilterOptions {
searchQuery?: string
selectedModels?: string[]
selectedSubcategory?: string | null
sortBy?: SortOption
}
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const searchQuery = ref('')
const selectedModels = ref<string[]>([])
const selectedSubcategory = ref<string | null>(null)
const sortBy = ref<SortOption>('recommended')
const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : []
})
// Get unique subcategories (tags) from current templates
const availableSubcategories = computed(() => {
const subcategorySet = new Set<string>()
templatesArray.value.forEach((template) => {
template.tags?.forEach((tag) => subcategorySet.add(tag))
})
return Array.from(subcategorySet).sort()
})
// Get unique models from all current templates (don't filter by subcategory for model list)
const availableModels = computed(() => {
const modelSet = new Set<string>()
templatesArray.value.forEach((template) => {
template.models?.forEach((model) => modelSet.add(model))
})
return Array.from(modelSet).sort()
})
const filteredTemplates = computed(() => {
let templateData = templatesArray.value
if (templateData.length === 0) {
return []
}
// Filter by search query
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim()
templateData = templateData.filter((template) => {
const searchableText = [
template.name,
template.title,
template.description,
template.sourceModule,
...(template.tags || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchableText.includes(query)
})
}
// Filter by subcategory
if (selectedSubcategory.value) {
templateData = templateData.filter((template) =>
template.tags?.includes(selectedSubcategory.value!)
)
}
// Filter by selected models
if (selectedModels.value.length > 0) {
templateData = templateData.filter((template) =>
template.models?.some((model) => selectedModels.value.includes(model))
)
}
// Sort templates
const sortedData = [...templateData]
switch (sortBy.value) {
case 'alphabetical':
sortedData.sort((a, b) =>
(a.title || a.name).localeCompare(b.title || b.name)
)
break
case 'newest':
sortedData.sort((a, b) => {
const dateA = new Date(a.date || '1970-01-01')
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
break
case 'recommended':
default:
// Keep original order for recommended (assumes templates are already in recommended order)
break
}
return sortedData
})
const resetFilters = () => {
searchQuery.value = ''
selectedModels.value = []
selectedSubcategory.value = null
sortBy.value = 'recommended'
}
const resetModelFilters = () => {
selectedModels.value = []
}
const filteredCount = computed(() => filteredTemplates.value.length)
return {
searchQuery,
selectedModels,
selectedSubcategory,
sortBy,
availableSubcategories,
availableModels,
filteredTemplates,
filteredCount,
resetFilters,
resetModelFilters
}
}

View File

@@ -41,14 +41,8 @@ export function useTemplateWorkflows() {
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstGroup = allTemplateGroups.value[0]
if (
firstGroup.subcategories.length > 0 &&
firstGroup.subcategories[0].modules.length > 0
) {
const firstCategory = firstGroup.subcategories[0].modules[0]
selectTemplateCategory(firstCategory)
}
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
@@ -69,9 +63,9 @@ export function useTemplateWorkflows() {
index = ''
) => {
const basePath =
// sourceModule === 'default'
api.fileURL(`/templates/${template.name}-1`)
// : api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
@@ -112,21 +106,16 @@ export function useTemplateWorkflows() {
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the USE CASES group
const useCasesGroup = allTemplateGroups.value.find(
(g) => g.label === t('templateWorkflows.group.useCases', 'USE CASES')
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
const allSubcategory = useCasesGroup?.subcategories.find(
(s) =>
s.label ===
t('templateWorkflows.subcategory.allTemplates', 'All Templates')
)
const allCategory = allSubcategory?.modules.find(
(m: WorkflowTemplates) => m.moduleName === 'all'
)
const template = allCategory?.templates.find(
(t: TemplateInfo) => t.name === id
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false

View File

@@ -11,9 +11,24 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[
'Comfy.RefreshNodeDefinitions',
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
]
],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[
['Manager'],
[
'Comfy.Manager.CustomNodesManager.ShowCustomNodesMenu',
'Comfy.Manager.ShowMissingPacks',
'Comfy.Manager.ShowUpdateAvailablePacks'
]
],
[
['Help'],
[

View File

@@ -892,5 +892,12 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
},
{
id: 'Comfy.Memory.AllowManualUnload',
name: 'Allow manual unload of models and execution cache via user command',
type: 'hidden',
defaultValue: true,
versionAdded: '1.18.0'
}
]

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Toggle the Custom Nodes Manager"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Show the Custom Nodes Manager"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "Custom Nodes (Legacy)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "Manager Menu (Legacy)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Install Missing Custom Nodes"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Check for Custom Node Updates"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},
"Comfy_Memory_UnloadModels": {
"label": "Unload Models"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Unload Models and Execution Cache"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -25,7 +25,6 @@
"confirmed": "Confirmed",
"reset": "Reset",
"resetAll": "Reset All",
"clearFilters": "Clear Filters",
"resetAllKeybindingsTooltip": "Reset all keybindings to default",
"customizeFolder": "Customize Folder",
"icon": "Icon",
@@ -110,6 +109,7 @@
"resultsCount": "Found {count} Results",
"status": "Status",
"description": "Description",
"warning": "Warning",
"name": "Name",
"category": "Category",
"sort": "Sort",
@@ -141,6 +141,7 @@
"releaseTitle": "{package} {version} Release",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",
"micPermissionDenied": "Microphone permission denied",
@@ -149,7 +150,13 @@
},
"manager": {
"title": "Custom Nodes Manager",
"failed": "Failed ({count})",
"legacyMenuNotAvailable": "Legacy manager menu is not available, defaulting to the new manager menu.",
"legacyManagerUI": "Use Legacy UI",
"legacyManagerUIDescription": "To use the legacy Manager UI, start ComfyUI with --enable-manager-legacy-ui",
"failed": "Failed",
"failedToInstall": "Failed to Install",
"installError": "Install Error",
"importFailedGenericError": "Package failed to import. Check the console for more details.",
"noNodesFound": "No nodes found",
"noNodesFoundDescription": "The pack's nodes either could not be parsed, or the pack is a frontend extension only and doesn't have any nodes.",
"installationQueue": "Installation Queue",
@@ -157,15 +164,21 @@
"dependencies": "Dependencies",
"inWorkflow": "In Workflow",
"infoPanelEmpty": "Click an item to see the info",
"restartToApplyChanges": "To apply changes, please restart ComfyUI",
"applyChanges": "Apply Changes",
"restartToApplyChanges": "Click 'Apply Changes' to finish setup",
"restartingBackend": "Restarting backend to apply changes...",
"extensionsSuccessfullyInstalled": "Extension(s) successfully installed and are ready to use!",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
"repository": "Repository",
"installingDependencies": "Installing dependencies...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling",
"update": "Update",
"uninstallSelected": "Uninstall Selected",
"updateSelected": "Update Selected",
"updateAll": "Update All",
"updatingAllPacks": "Updating all packages",
"license": "License",
"nightlyVersion": "Nightly",
@@ -184,14 +197,16 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"packsSelected": "packs selected",
"status": {
"active": "Active",
"pending": "Pending",
"flagged": "Flagged",
"deleted": "Deleted",
"banned": "Banned",
"unknown": "Unknown"
"unknown": "Unknown",
"conflicting": "Conflicting",
"importFailed": "Install Error"
},
"sort": {
"downloads": "Most Popular",
@@ -203,6 +218,34 @@
"nodePack": "Node Pack",
"enabled": "Enabled",
"disabled": "Disabled"
},
"conflicts": {
"title": "Conflicts Detected!",
"description": "Weve detected conflicts between some of your extensions and the new version of ComfyUI. By updating you risk breaking workflows that rely on those extensions.",
"info": "If you continue with the update, the conflicting extensions will be disabled automatically. You can review and manage them anytime in the ComfyUI Manager.",
"extensionAtRisk": "Extension at Risk",
"conflicts": "Conflicts",
"importFailedExtensions": "Import Failed Extensions",
"conflictInfoTitle": "Why is this happening?",
"installAnyway": "Install Anyway",
"enableAnyway": "Enable Anyway",
"understood": "Understood",
"warningBanner": {
"title": "Some extensions are disabled due to incompatibility with your current setup",
"message": "These extensions require versions of system packages that differ from your current setup. Installing them may override core dependencies and affect other extensions or workflows.",
"button": "Learn More..."
},
"conflictMessages": {
"comfyui_version": "ComfyUI version mismatch (current: {current}, required: {required})",
"frontend_version": "Frontend version mismatch (current: {current}, required: {required})",
"os": "Operating system not supported (current: {current}, required: {required})",
"accelerator": "GPU/Accelerator not supported (available: {current}, required: {required})",
"generic": "Compatibility issue (current: {current}, required: {required})",
"banned": "This package is banned for security reasons",
"pending": "Security verification pending - compatibility cannot be verified",
"import_failed": "Import Failed"
},
"warningTooltip": "This package may have compatibility issues with your current environment"
}
},
"issueReport": {
@@ -494,6 +537,7 @@
"docs": "Docs",
"github": "Github",
"helpFeedback": "Help & Feedback",
"managerExtension": "Manager Extension",
"more": "More...",
"whatsNew": "What's New?",
"clickToLearnMore": "Click to learn more →",
@@ -550,17 +594,6 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
"loadingMore": "Loading more templates...",
"searchPlaceholder": "Search templates...",
"modelFilter": "Model",
"sort": "Sort",
"sortRecommended": "Recommended",
"sortAlphabetical": "Alphabetical",
"sortNewest": "Newest",
"modelsSelected": "{count} models",
"allSubcategories": "All",
"tutorial": "Tutorial",
"useTemplate": "Use Template",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
@@ -578,26 +611,6 @@
"LLM API": "LLM API",
"All": "All Templates"
},
"group": {
"useCases": "USE CASES",
"toolsBuilding": "TOOLS & BUILDING",
"customCommunity": "CUSTOM & COMMUNITY"
},
"subcategory": {
"allTemplates": "All Templates",
"imageCreation": "Image Creation",
"videoAnimation": "Video & Animation",
"spatial": "3D & Spatial",
"audio": "Audio",
"advancedApis": "Advanced APIs",
"postProcessing": "Post-Processing & Utilities",
"customNodes": "Custom Nodes",
"communityPicks": "Community Picks"
},
"view": {
"allTemplates": "All Templates",
"recentItems": "Recent Items"
},
"templateDescription": {
"Basics": {
"default": "Generate images from text prompts.",
@@ -962,6 +975,7 @@
"menuLabels": {
"Workflow": "Workflow",
"Edit": "Edit",
"Manager": "Manager",
"Help": "Help",
"Check for Updates": "Check for Updates",
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
@@ -1015,11 +1029,22 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Show the Custom Nodes Manager": "Show the Custom Nodes Manager",
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Custom Nodes (Beta)": "Custom Nodes (Beta)",
"Custom Nodes Manager": "Custom Nodes Manager",
"Install Missing": "Install Missing",
"Custom Nodes (Legacy)": "Custom Nodes (Legacy)",
"Manager Menu (Legacy)": "Manager Menu (Legacy)",
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
"Check for Custom Node Updates": "Check for Custom Node Updates",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Cargar flujo de trabajo predeterminado"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Administrador de nodos personalizados"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Nodos personalizados (Beta)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "Nodos personalizados (heredados)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "Menú del gestor (heredado)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Instalar faltantes"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Buscar actualizaciones"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Alternar diálogo de progreso del administrador"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Abrir editor de máscara para el nodo seleccionado"
},
"Comfy_Memory_UnloadModels": {
"label": "Descargar modelos"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Descargar modelos y caché de ejecución"
},
"Comfy_NewBlankWorkflow": {
"label": "Nuevo flujo de trabajo en blanco"
},

View File

@@ -272,11 +272,11 @@
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"color": "Color",
"comingSoon": "Próximamente",
"command": "Comando",
"commandProhibited": "El comando {command} está prohibido. Contacta a un administrador para más información.",
"community": "Comunidad",
"completed": "Completado",
"confirm": "Confirmar",
@@ -628,12 +628,14 @@
"title": "Mantenimiento"
},
"manager": {
"applyChanges": "Aplicar cambios",
"changingVersion": "Cambiando versión de {from} a {to}",
"createdBy": "Creado Por",
"dependencies": "Dependencias",
"discoverCommunityContent": "Descubre paquetes de nodos, extensiones y más creados por la comunidad...",
"downloads": "Descargas",
"errorConnecting": "Error al conectar con el Registro de Nodos Comfy.",
"extensionsSuccessfullyInstalled": "¡Extensión(es) instalada(s) correctamente y lista(s) para usar!",
"failed": "Falló ({count})",
"filter": {
"disabled": "Deshabilitado",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"installingDependencies": "Instalando dependencias...",
"lastUpdated": "Última Actualización",
"latestVersion": "Última",
"legacyManagerUI": "Usar UI antigua",
"legacyManagerUIDescription": "Para usar la UI antigua del Manager, inicia ComfyUI con --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "El menú del administrador antiguo no está disponible en esta versión de ComfyUI. Por favor, utiliza el nuevo menú del administrador en su lugar.",
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"nightlyVersion": "Nocturna",
@@ -658,6 +664,7 @@
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
"restartToApplyChanges": "Para aplicar los cambios, por favor reinicia ComfyUI",
"restartingBackend": "Reiniciando el backend para aplicar los cambios...",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar Versión",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "Desinstalar Seleccionado",
"uninstalling": "Desinstalando",
"update": "Actualizar",
"updateAll": "Actualizar todo",
"updateSelected": "Actualizar seleccionados",
"updatingAllPacks": "Actualizando todos los paquetes",
"version": "Versión"
},
@@ -747,6 +756,8 @@
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
"Canvas Toggle Minimap": "Lienzo: Alternar minimapa",
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
"Check for Custom Node Updates": "Buscar Actualizaciones de Nodos Personalizados",
"Check for Updates": "Buscar actualizaciones",
"Clear Pending Tasks": "Borrar tareas pendientes",
"Clear Workflow": "Borrar flujo de trabajo",
@@ -760,6 +771,10 @@
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes (Beta)": "Nodos personalizados (Beta)",
"Custom Nodes (Legacy)": "Nodos personalizados (Antiguo)",
"Custom Nodes (Legacy)": "Nodos Personalizados (Legado)",
"Custom Nodes Manager": "Administrador de Nodos Personalizados",
"Decrease Brush Size in MaskEditor": "Disminuir tamaño del pincel en MaskEditor",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
@@ -773,9 +788,14 @@
"Group Selected Nodes": "Agrupar nodos seleccionados",
"Help": "Ayuda",
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
"Install Missing": "Instalar Faltantes",
"Install Missing Custom Nodes": "Instalar nodos personalizados faltantes",
"Install Missing Custom Nodes": "Instalar Nodos Personalizados Faltantes",
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Manager": "Administrador",
"Manager Menu (Legacy)": "Menú de Gestión (Legado)",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
@@ -809,6 +829,7 @@
"Save": "Guardar",
"Save As": "Guardar como",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Show the Custom Nodes Manager": "Mostrar el Administrador de Nodos Personalizados",
"Sign Out": "Cerrar sesión",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Focus Mode": "Alternar modo de enfoque",
@@ -820,10 +841,11 @@
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Unload Models": "Descargar modelos",
"Unload Models and Execution Cache": "Descargar modelos y caché de ejecución",
"Workflow": "Flujo de trabajo",
"Zoom In": "Acercar",
"Zoom Out": "Alejar"
@@ -1232,8 +1254,6 @@
"Video": "Video",
"Video API": "API de Video"
},
"loadingMore": "Cargando más plantillas...",
"searchPlaceholder": "Buscar plantillas...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Charger le flux de travail par défaut"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Nœuds personnalisés (Beta)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "Nœuds personnalisés (héritage)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "Menu du gestionnaire (hérité)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Installer manquants"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Vérifier les mises à jour"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Ouvrir l'éditeur de masque pour le nœud sélectionné"
},
"Comfy_Memory_UnloadModels": {
"label": "Décharger les modèles"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Décharger les modèles et le cache d'exécution"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -272,11 +272,11 @@
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
"command": "Commande",
"commandProhibited": "La commande {command} est interdite. Contactez un administrateur pour plus d'informations.",
"community": "Communauté",
"completed": "Terminé",
"confirm": "Confirmer",
@@ -628,12 +628,14 @@
"title": "Maintenance"
},
"manager": {
"applyChanges": "Appliquer les modifications",
"changingVersion": "Changement de version de {from} à {to}",
"createdBy": "Créé par",
"dependencies": "Dépendances",
"discoverCommunityContent": "Découvrez les packs de nœuds, extensions et plus encore créés par la communauté...",
"downloads": "Téléchargements",
"errorConnecting": "Erreur de connexion au registre de nœuds Comfy.",
"extensionsSuccessfullyInstalled": "Extension(s) installée(s) avec succès et prêtes à l'emploi !",
"failed": "Échoué ({count})",
"filter": {
"disabled": "Désactivé",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"installingDependencies": "Installation des dépendances...",
"lastUpdated": "Dernière mise à jour",
"latestVersion": "Dernière",
"legacyManagerUI": "Utiliser l'interface utilisateur héritée",
"legacyManagerUIDescription": "Pour utiliser l'interface utilisateur de gestion héritée, démarrez ComfyUI avec --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "Le menu du gestionnaire de l'ancienne version n'est pas disponible dans cette version de ComfyUI. Veuillez utiliser le nouveau menu du gestionnaire à la place.",
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"nightlyVersion": "Nocturne",
@@ -658,6 +664,7 @@
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"restartToApplyChanges": "Pour appliquer les modifications, veuillez redémarrer ComfyUI",
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "Désinstaller sélectionné",
"uninstalling": "Désinstallation",
"update": "Mettre à jour",
"updateAll": "Tout mettre à jour",
"updateSelected": "Mettre à jour la sélection",
"updatingAllPacks": "Mise à jour de tous les paquets",
"version": "Version"
},
@@ -747,7 +756,10 @@
"Canvas Toggle Link Visibility": "Basculer la visibilité du lien de la toile",
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
"Canvas Toggle Minimap": "Basculer la mini-carte du canevas",
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
"Check for Updates": "Vérifier les mises à jour",
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
"Check for Updates": "Vérifier les Mises à Jour",
"Clear Pending Tasks": "Effacer les tâches en attente",
"Clear Workflow": "Effacer le flux de travail",
"Clipspace": "Espace de clip",
@@ -760,7 +772,11 @@
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes (Beta)": "Nœuds personnalisés (Beta)",
"Custom Nodes (Legacy)": "Nœuds personnalisés (Ancienne version)",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Decrease Brush Size in MaskEditor": "Réduire la taille du pinceau dans MaskEditor",
"Custom Nodes (Legacy)": "Nœuds personnalisés (héritage)",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -773,9 +789,14 @@
"Group Selected Nodes": "Grouper les nœuds sélectionnés",
"Help": "Aide",
"Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor",
"Install Missing Custom Nodes": "Installer les nœuds personnalisés manquants",
"Install Missing": "Installer Manquants",
"Install Missing Custom Nodes": "Installer les nœuds personnalisés manquants",
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Manager": "Gestionnaire",
"Manager Menu (Legacy)": "Menu du gestionnaire (héritage)",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
@@ -809,6 +830,7 @@
"Save": "Enregistrer",
"Save As": "Enregistrer sous",
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Show the Custom Nodes Manager": "Afficher le gestionnaire de nœuds personnalisés",
"Sign Out": "Se déconnecter",
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Focus Mode": "Basculer le mode focus",
@@ -820,10 +842,11 @@
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Unload Models": "Décharger les modèles",
"Unload Models and Execution Cache": "Décharger les modèles et le cache d'exécution",
"Workflow": "Flux de travail",
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"
@@ -1232,8 +1255,6 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
"loadingMore": "Chargement de plus de modèles...",
"searchPlaceholder": "Rechercher des modèles...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "デフォルトのワークフローを読み込む"
},
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "カスタムノード(ベータ版)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "カスタムノード(レガシー)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "マネージャーメニュー(レガシー)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "不足しているパックをインストール"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "更新を確認"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "選択したノードのマスクエディタを開く"
},
"Comfy_Memory_UnloadModels": {
"label": "モデルのアンロード"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "モデルと実行キャッシュのアンロード"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -272,11 +272,11 @@
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
"command": "コマンド",
"commandProhibited": "コマンド {command} は禁止されています。詳細は管理者にお問い合わせください。",
"community": "コミュニティ",
"completed": "完了",
"confirm": "確認",
@@ -628,12 +628,14 @@
"title": "メンテナンス"
},
"manager": {
"applyChanges": "変更を適用",
"changingVersion": "バージョンを {from} から {to} に変更",
"createdBy": "作成者",
"dependencies": "依存関係",
"discoverCommunityContent": "コミュニティが作成したノードパック、拡張機能などを探す...",
"downloads": "ダウンロード",
"errorConnecting": "Comfy Node Registryへの接続エラー。",
"extensionsSuccessfullyInstalled": "拡張機能のインストールが完了し、使用可能になりました!",
"failed": "失敗しました ({count})",
"filter": {
"disabled": "無効",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"installingDependencies": "依存関係をインストールしています...",
"lastUpdated": "最終更新日",
"latestVersion": "最新",
"legacyManagerUI": "レガシーUIを使用する",
"legacyManagerUIDescription": "レガシーManager UIを使用するには、--enable-manager-legacy-uiを付けてComfyUIを起動してください",
"legacyMenuNotAvailable": "このバージョンのComfyUIでは、レガシーマネージャーメニューは利用できません。新しいマネージャーメニューを使用してください。",
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"nightlyVersion": "ナイトリー",
@@ -658,6 +664,7 @@
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"restartToApplyChanges": "変更を適用するには、ComfyUIを再起動してください",
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "選択したものをアンインストール",
"uninstalling": "アンインストール中",
"update": "更新",
"updateAll": "すべて更新",
"updateSelected": "選択を更新",
"updatingAllPacks": "すべてのパッケージを更新中",
"version": "バージョン"
},
@@ -747,7 +756,10 @@
"Canvas Toggle Link Visibility": "キャンバスのリンク表示を切り替え",
"Canvas Toggle Lock": "キャンバスのロックを切り替え",
"Canvas Toggle Minimap": "キャンバス ミニマップの切り替え",
"Check for Custom Node Updates": "カスタムノードのアップデートを確認",
"Check for Updates": "更新を確認する",
"Check for Custom Node Updates": "カスタムノードのアップデートを確認",
"Check for Updates": "更新を確認",
"Clear Pending Tasks": "保留中のタスクをクリア",
"Clear Workflow": "ワークフローをクリア",
"Clipspace": "クリップスペース",
@@ -760,7 +772,11 @@
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes (Beta)": "カスタムノード(ベータ)",
"Custom Nodes (Legacy)": "カスタムノード(レガシー)",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Decrease Brush Size in MaskEditor": "マスクエディタでブラシサイズを小さくする",
"Custom Nodes (Legacy)": "カスタムノード(レガシー)",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -773,9 +789,14 @@
"Group Selected Nodes": "選択したノードをグループ化",
"Help": "ヘルプ",
"Increase Brush Size in MaskEditor": "マスクエディタでブラシサイズを大きくする",
"Install Missing Custom Nodes": "不足しているカスタムノードをインストール",
"Install Missing": "不足しているものをインストール",
"Install Missing Custom Nodes": "不足しているカスタムノードをインストール",
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Manager": "マネージャー",
"Manager Menu (Legacy)": "マネージャーメニュー(レガシー)",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
@@ -809,6 +830,7 @@
"Save": "保存",
"Save As": "名前を付けて保存",
"Show Settings Dialog": "設定ダイアログを表示",
"Show the Custom Nodes Manager": "カスタムノードマネージャーを表示",
"Sign Out": "サインアウト",
"Toggle Bottom Panel": "下部パネルの切り替え",
"Toggle Focus Mode": "フォーカスモードの切り替え",
@@ -820,10 +842,11 @@
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Unload Models": "モデルのアンロード",
"Unload Models and Execution Cache": "モデルと実行キャッシュのアンロード",
"Workflow": "ワークフロー",
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
@@ -1232,8 +1255,6 @@
"Video": "ビデオ",
"Video API": "動画API"
},
"loadingMore": "さらにテンプレートを読み込み中...",
"searchPlaceholder": "テンプレートを検索...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "기본 워크플로 로드"
},
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "사용자 정의 노드 (베타)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "커스텀 노드 (레거시)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "매니저 메뉴 (레거시)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "누락된 팩 설치"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "업데이트 확인"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "진행 상황 대화 상자 전환"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "선택한 노드 마스크 편집기 열기"
},
"Comfy_Memory_UnloadModels": {
"label": "모델 언로드"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "모델 및 실행 캐시 언로드"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -272,11 +272,11 @@
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
"command": "명령",
"commandProhibited": "명령 {command}은 금지되었습니다. 자세한 정보는 관리자에게 문의하십시오.",
"community": "커뮤니티",
"completed": "완료됨",
"confirm": "확인",
@@ -628,12 +628,14 @@
"title": "유지 보수"
},
"manager": {
"applyChanges": "변경 사항 적용",
"changingVersion": "{from}에서 {to}(으)로 버전 변경 중",
"createdBy": "작성자",
"dependencies": "의존성",
"discoverCommunityContent": "커뮤니티에서 만든 노드 팩 및 확장 프로그램을 찾아보세요...",
"downloads": "다운로드",
"errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.",
"extensionsSuccessfullyInstalled": "확장 프로그램이 성공적으로 설치되어 사용할 준비가 되었습니다!",
"failed": "실패 ({count})",
"filter": {
"disabled": "비활성화",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"installingDependencies": "의존성 설치 중...",
"lastUpdated": "마지막 업데이트",
"latestVersion": "최신",
"legacyManagerUI": "레거시 UI 사용",
"legacyManagerUIDescription": "레거시 매니저 UI를 사용하려면, ComfyUI를 --enable-manager-legacy-ui로 시작하세요",
"legacyMenuNotAvailable": "이 버전의 ComfyUI에서는 레거시 매니저 메뉴를 사용할 수 없습니다. 대신 새로운 매니저 메뉴를 사용하십시오.",
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"nightlyVersion": "최신 테스트 버전(nightly)",
@@ -658,6 +664,7 @@
"packsSelected": "선택한 노드 팩",
"repository": "저장소",
"restartToApplyChanges": "변경 사항을 적용하려면 ComfyUI를 재시작해 주세요",
"restartingBackend": "변경 사항을 적용하기 위해 백엔드를 재시작하는 중...",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "선택 항목 제거",
"uninstalling": "제거 중",
"update": "업데이트",
"updateAll": "전체 업데이트",
"updateSelected": "선택 항목 업데이트",
"updatingAllPacks": "모든 패키지 업데이트 중",
"version": "버전"
},
@@ -747,6 +756,7 @@
"Canvas Toggle Link Visibility": "캔버스 토글 링크 가시성",
"Canvas Toggle Lock": "캔버스 토글 잠금",
"Canvas Toggle Minimap": "캔버스 미니맵 전환",
"Check for Custom Node Updates": "커스텀 노드 업데이트 확인",
"Check for Updates": "업데이트 확인",
"Clear Pending Tasks": "보류 중인 작업 제거하기",
"Clear Workflow": "워크플로 지우기",
@@ -760,7 +770,11 @@
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes (Beta)": "사용자 정의 노드 (베타)",
"Custom Nodes (Legacy)": "사용자 정의 노드 (레거시)",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
"Custom Nodes (Legacy)": "커스텀 노드 (레거시)",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -773,9 +787,14 @@
"Group Selected Nodes": "선택한 노드 그룹화",
"Help": "도움말",
"Increase Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 늘리기",
"Install Missing Custom Nodes": "누락된 커스텀 노드 설치",
"Install Missing": "누락된 설치",
"Install Missing Custom Nodes": "누락된 커스텀 노드 설치",
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Manager": "매니저",
"Manager Menu (Legacy)": "매니저 메뉴 (레거시)",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
@@ -809,6 +828,7 @@
"Save": "저장",
"Save As": "다른 이름으로 저장",
"Show Settings Dialog": "설정 대화상자 표시",
"Show the Custom Nodes Manager": "커스텀 노드 관리자 표시",
"Sign Out": "로그아웃",
"Toggle Bottom Panel": "하단 패널 전환",
"Toggle Focus Mode": "포커스 모드 전환",
@@ -819,11 +839,12 @@
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Unload Models": "모델 언로드",
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Workflow": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소"
@@ -1232,8 +1253,6 @@
"Video": "비디오",
"Video API": "비디오 API"
},
"loadingMore": "템플릿을 더 불러오는 중...",
"searchPlaceholder": "템플릿 검색...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "Загрузить стандартный рабочий процесс"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "Пользовательские узлы (Бета)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "Пользовательские узлы (устаревшие)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "Меню менеджера (устаревшее)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "Установить отсутствующие"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "Проверить наличие обновлений"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Открыть редактор масок для выбранной ноды"
},
"Comfy_Memory_UnloadModels": {
"label": "Выгрузить модели"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "Выгрузить модели и кэш выполнения"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -272,11 +272,11 @@
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
"command": "Команда",
"commandProhibited": "Команда {command} запрещена. Свяжитесь с администратором для получения дополнительной информации.",
"community": "Сообщество",
"completed": "Завершено",
"confirm": "Подтвердить",
@@ -628,12 +628,14 @@
"title": "Обслуживание"
},
"manager": {
"applyChanges": "Применить изменения",
"changingVersion": "Изменение версии с {from} на {to}",
"createdBy": "Создано",
"dependencies": "Зависимости",
"discoverCommunityContent": "Откройте для себя пакеты узлов, расширения и многое другое, созданные сообществом...",
"downloads": "Загрузки",
"errorConnecting": "Ошибка подключения к реестру Comfy Node.",
"extensionsSuccessfullyInstalled": "Расширение(я) успешно установлены и готовы к использованию!",
"failed": "Не удалось ({count})",
"filter": {
"disabled": "Отключено",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"installingDependencies": "Установка зависимостей...",
"lastUpdated": "Последнее обновление",
"latestVersion": "Последняя",
"legacyManagerUI": "Использовать устаревший UI",
"legacyManagerUIDescription": "Чтобы использовать устаревший UI менеджера, запустите ComfyUI с --enable-manager-legacy-ui",
"legacyMenuNotAvailable": "Устаревшее меню менеджера недоступно в этой версии ComfyUI. Пожалуйста, используйте новое меню менеджера.",
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"nightlyVersion": "Ночная",
@@ -658,6 +664,7 @@
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"restartToApplyChanges": "Чтобы применить изменения, пожалуйста, перезапустите ComfyUI",
"restartingBackend": "Перезапуск бэкенда для применения изменений...",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "Удалить выбранное",
"uninstalling": "Удаление",
"update": "Обновить",
"updateAll": "Обновить все",
"updateSelected": "Обновить выбранное",
"updatingAllPacks": "Обновление всех пакетов",
"version": "Версия"
},
@@ -747,7 +756,10 @@
"Canvas Toggle Link Visibility": "Переключение видимости ссылки на холст",
"Canvas Toggle Lock": "Переключение блокировки холста",
"Canvas Toggle Minimap": "Показать/скрыть миникарту на холсте",
"Check for Custom Node Updates": "Проверить обновления пользовательских узлов",
"Check for Updates": "Проверить наличие обновлений",
"Check for Custom Node Updates": "Проверить обновления пользовательских узлов",
"Check for Updates": "Проверить Обновления",
"Clear Pending Tasks": "Очистить ожидающие задачи",
"Clear Workflow": "Очистить рабочий процесс",
"Clipspace": "Клиппространство",
@@ -760,7 +772,11 @@
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes (Beta)": "Пользовательские узлы (Бета)",
"Custom Nodes (Legacy)": "Пользовательские узлы (Устаревшие)",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Decrease Brush Size in MaskEditor": "Уменьшить размер кисти в MaskEditor",
"Custom Nodes (Legacy)": "Пользовательские узлы (устаревшие)",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -773,9 +789,14 @@
"Group Selected Nodes": "Сгруппировать выбранные ноды",
"Help": "Помощь",
"Increase Brush Size in MaskEditor": "Увеличить размер кисти в MaskEditor",
"Install Missing Custom Nodes": "Установить отсутствующие пользовательские узлы",
"Install Missing": "Установить Отсутствующие",
"Install Missing Custom Nodes": "Установить отсутствующие пользовательские узлы",
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Manager": "Менеджер",
"Manager Menu (Legacy)": "Меню управления (устаревшее)",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
@@ -809,6 +830,7 @@
"Save": "Сохранить",
"Save As": "Сохранить как",
"Show Settings Dialog": "Показать диалог настроек",
"Show the Custom Nodes Manager": "Показать менеджер пользовательских узлов",
"Sign Out": "Выйти",
"Toggle Bottom Panel": "Переключить нижнюю панель",
"Toggle Focus Mode": "Переключить режим фокуса",
@@ -820,10 +842,11 @@
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Показать/скрыть боковую панель рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Unload Models": "Выгрузить модели",
"Unload Models and Execution Cache": "Выгрузить модели и кэш выполнения",
"Workflow": "Рабочий процесс",
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
@@ -1232,8 +1255,6 @@
"Video": "Видео",
"Video API": "Video API"
},
"loadingMore": "Загрузка дополнительных шаблонов...",
"searchPlaceholder": "Поиск шаблонов...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D",

View File

@@ -155,8 +155,20 @@
"Comfy_LoadDefaultWorkflow": {
"label": "載入預設工作流程"
},
"Comfy_Manager_CustomNodesManager": {
"label": "切換自訂節點管理器"
"Comfy_Manager_CustomNodesManager_ShowCustomNodesMenu": {
"label": "顯示自訂節點管理器"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "自訂節點(舊版)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "管理選單(舊版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "安裝缺少的自訂節點"
},
"Comfy_Manager_ShowUpdateAvailablePacks": {
"label": "檢查自訂節點更新"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切換自訂節點管理器進度條"
@@ -170,6 +182,12 @@
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "為選取的節點開啟 Mask 編輯器"
},
"Comfy_Memory_UnloadModels": {
"label": "卸載模型"
},
"Comfy_Memory_UnloadModelsAndExecutionCache": {
"label": "卸載模型與執行快取"
},
"Comfy_NewBlankWorkflow": {
"label": "新增空白工作流程"
},

View File

@@ -272,11 +272,11 @@
"category": "分類",
"choose_file_to_upload": "選擇要上傳的檔案",
"clear": "清除",
"clearFilters": "清除篩選",
"close": "關閉",
"color": "顏色",
"comingSoon": "即將推出",
"command": "指令",
"commandProhibited": "指令 {command} 已被禁止。如需更多資訊,請聯絡管理員。",
"community": "社群",
"completed": "已完成",
"confirm": "確認",
@@ -628,12 +628,14 @@
"title": "維護"
},
"manager": {
"applyChanges": "套用變更",
"changingVersion": "正在將版本從 {from} 變更為 {to}",
"createdBy": "建立者",
"dependencies": "相依套件",
"discoverCommunityContent": "探索社群製作的節點包、擴充功能等...",
"downloads": "下載次數",
"errorConnecting": "連線至 Comfy Node Registry 時發生錯誤。",
"extensionsSuccessfullyInstalled": "擴充功能安裝成功,已可使用!",
"failed": "失敗({count}",
"filter": {
"disabled": "已停用",
@@ -645,8 +647,12 @@
"installAllMissingNodes": "安裝所有缺少的節點",
"installSelected": "安裝所選項目",
"installationQueue": "安裝佇列",
"installingDependencies": "正在安裝相依套件……",
"lastUpdated": "最後更新",
"latestVersion": "最新版本",
"legacyManagerUI": "使用舊版介面",
"legacyManagerUIDescription": "若要使用舊版管理介面,請以 --enable-manager-legacy-ui 啟動 ComfyUI",
"legacyMenuNotAvailable": "舊版管理選單不可用,已預設切換至新版管理選單。",
"license": "授權條款",
"loadingVersions": "正在載入版本...",
"nightlyVersion": "每夜建置版",
@@ -658,6 +664,7 @@
"packsSelected": "已選擇套件",
"repository": "儲存庫",
"restartToApplyChanges": "請重新啟動 ComfyUI 以套用變更",
"restartingBackend": "正在重新啟動後端以套用變更……",
"searchPlaceholder": "搜尋",
"selectVersion": "選擇版本",
"sort": {
@@ -682,6 +689,8 @@
"uninstallSelected": "解除安裝所選項目",
"uninstalling": "正在解除安裝",
"update": "更新",
"updateAll": "全部更新",
"updateSelected": "更新所選項目",
"updatingAllPacks": "正在更新所有套件",
"version": "版本"
},
@@ -747,6 +756,7 @@
"Canvas Toggle Link Visibility": "切換連結可見性",
"Canvas Toggle Lock": "切換畫布鎖定",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Custom Node Updates": "檢查自訂節點更新",
"Check for Updates": "檢查更新",
"Clear Pending Tasks": "清除待處理任務",
"Clear Workflow": "清除工作流程",
@@ -760,6 +770,8 @@
"Contact Support": "聯絡支援",
"Convert Selection to Subgraph": "將選取內容轉為子圖",
"Convert selected nodes to group node": "將選取節點轉為群組節點",
"Custom Nodes (Legacy)": "自訂節點(舊版)",
"Custom Nodes Manager": "自訂節點管理員",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
"Delete Selected Items": "刪除選取項目",
"Desktop User Guide": "桌面應用程式使用指南",
@@ -773,9 +785,12 @@
"Group Selected Nodes": "群組選取節點",
"Help": "說明",
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小",
"Install Missing Custom Nodes": "安裝缺少的自訂節點",
"Interrupt": "中斷",
"Load Default Workflow": "載入預設工作流程",
"Manage group nodes": "管理群組節點",
"Manager": "管理員",
"Manager Menu (Legacy)": "管理員選單(舊版)",
"Move Selected Nodes Down": "選取節點下移",
"Move Selected Nodes Left": "選取節點左移",
"Move Selected Nodes Right": "選取節點右移",
@@ -809,6 +824,7 @@
"Save": "儲存",
"Save As": "另存新檔",
"Show Settings Dialog": "顯示設定對話框",
"Show the Custom Nodes Manager": "顯示自訂節點管理員",
"Sign Out": "登出",
"Toggle Bottom Panel": "切換下方面板",
"Toggle Focus Mode": "切換專注模式",
@@ -820,10 +836,11 @@
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
"Ungroup selected group nodes": "取消群組選取的群組節點",
"Unload Models": "卸載模型",
"Unload Models and Execution Cache": "卸載模型與執行快取",
"Workflow": "工作流程",
"Zoom In": "放大",
"Zoom Out": "縮小"
@@ -1232,8 +1249,6 @@
"Video": "影片",
"Video API": "影片 API"
},
"loadingMore": "正在載入更多範本...",
"searchPlaceholder": "搜尋範本...",
"template": {
"3D": {
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",

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