Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
6a5fcc5357 cancel inflight create session 2025-10-29 20:44:31 -07:00
bymyself
52876da4a9 handle in flight on session creation 2025-10-29 19:00:10 -07:00
98 changed files with 746 additions and 4317 deletions

View File

@@ -23,10 +23,10 @@ TEST_COMFYUI_DIR=/home/ComfyUI
# Whether to enable minification of the frontend code.
ENABLE_MINIFY=true
# Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site
# Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site
# packages.
DISABLE_TEMPLATES_PROXY=false
@@ -37,8 +37,3 @@ DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging

View File

@@ -142,3 +142,4 @@ jobs:
documentation
automated
draft: true
assignees: ${{ github.repository_owner }}

View File

@@ -503,7 +503,7 @@ export class NodeReference {
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
position: { x: 50, y: 50 },
force: true
})
await this.comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.0",
"version": "1.31.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -57,7 +57,6 @@
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",

View File

@@ -157,8 +157,6 @@
--button-surface: var(--color-white);
--button-surface-contrast: var(--color-black);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
--code-bg-color: rgb(96 165 250 / 0.2);
@@ -258,8 +256,6 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -332,7 +328,6 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
--interface-menu-component-surface-hovered
@@ -1198,6 +1193,31 @@ audio.comfy-audio.empty-audio-widget {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
/* Load3d styles */
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation {
display: flex;
flex-direction: column;
background: transparent;
flex: 1;
position: relative;
overflow: hidden;
}
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas {
display: flex;
width: 100% !important;
height: 100% !important;
}
/* End of Load3d styles */
/* [Desktop] Electron window specific styles */
.app-drag {
app-region: drag;

224
pnpm-lock.yaml generated
View File

@@ -69,9 +69,6 @@ catalogs:
'@primevue/themes':
specifier: ^4.2.5
version: 4.2.5
'@sentry/vite-plugin':
specifier: ^4.6.0
version: 4.6.0
'@sentry/vue':
specifier: ^8.48.0
version: 8.48.0
@@ -501,9 +498,6 @@ importers:
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.0.4
'@sentry/vite-plugin':
specifier: 'catalog:'
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
@@ -2778,78 +2772,14 @@ packages:
resolution: {integrity: sha512-csILVupc5RkrsTrncuUTGmlB56FQSFjXPYWG8I8yBTGlXEJ+o8oTuF6+55R4vbw3EIzBveXWi4kEBbnQlXW/eg==}
engines: {node: '>=14.18'}
'@sentry/babel-plugin-component-annotate@4.6.0':
resolution: {integrity: sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ==}
engines: {node: '>= 14'}
'@sentry/browser@8.48.0':
resolution: {integrity: sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==}
engines: {node: '>=14.18'}
'@sentry/bundler-plugin-core@4.6.0':
resolution: {integrity: sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g==}
engines: {node: '>= 14'}
'@sentry/cli-darwin@2.57.0':
resolution: {integrity: sha512-v1wYQU3BcCO+Z3OVxxO+EnaW4oQhuOza6CXeYZ0z5ftza9r0QQBLz3bcZKTVta86xraNm0z8GDlREwinyddOxQ==}
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-linux-arm64@2.57.0':
resolution: {integrity: sha512-Kh1jTsMV5Fy/RvB381N/woXe1qclRMqsG6kM3Gq6m6afEF/+k3PyQdNW3HXAola6d63EptokLtxPG2xjWQ+w9Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd, android]
'@sentry/cli-linux-arm@2.57.0':
resolution: {integrity: sha512-uNHB8xyygqfMd1/6tFzl9NUkuVefg7jdZtM/vVCQVaF/rJLWZ++Wms+LLhYyKXKN8yd7J9wy7kTEl4Qu4jWbGQ==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd, android]
'@sentry/cli-linux-i686@2.57.0':
resolution: {integrity: sha512-EYXghoK/tKd0zqz+KD/ewXXE3u1HLCwG89krweveytBy/qw7M5z58eFvw+iGb1Vnbl1f/fRD0G4E0AbEsPfmpg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd, android]
'@sentry/cli-linux-x64@2.57.0':
resolution: {integrity: sha512-CyZrP/ssHmAPLSzfd4ydy7icDnwmDD6o3QjhkWwVFmCd+9slSBMQxpIqpamZmrWE6X4R+xBRbSUjmdoJoZ5yMw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd, android]
'@sentry/cli-win32-arm64@2.57.0':
resolution: {integrity: sha512-wji/GGE4Lh5I/dNCsuVbg6fRvttvZRG6db1yPW1BSvQRh8DdnVy1CVp+HMqSq0SRy/S4z60j2u+m4yXMoCL+5g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@sentry/cli-win32-i686@2.57.0':
resolution: {integrity: sha512-hWvzyD7bTPh3b55qvJ1Okg3Wbl0Km8xcL6KvS7gfBl6uss+I6RldmQTP0gJKdHSdf/QlJN1FK0b7bLnCB3wHsg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-x64@2.57.0':
resolution: {integrity: sha512-QWYV/Y0sbpDSTyA4XQBOTaid4a6H2Iwa1Z8UI+qNxFlk0ADSEgIqo2NrRHDU8iRnghTkecQNX1NTt/7mXN3f/A==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli@2.57.0':
resolution: {integrity: sha512-oC4HPrVIX06GvUTgK0i+WbNgIA9Zl5YEcwf9N4eWFJJmjonr2j4SML9Hn2yNENbUWDgwepy4MLod3P8rM4bk/w==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@8.48.0':
resolution: {integrity: sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==}
engines: {node: '>=14.18'}
'@sentry/vite-plugin@4.6.0':
resolution: {integrity: sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw==}
engines: {node: '>= 14'}
'@sentry/vue@8.48.0':
resolution: {integrity: sha512-hqm9X7hz1vMQQB1HBYezrDBQihZk6e/MxWIG1wMJoClcBnD1Sh7y+D36UwaQlR4Gr/Ftiz+Bb0DxuAYHoUS4ow==}
engines: {node: '>=14.18'}
@@ -3756,10 +3686,6 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -5033,9 +4959,6 @@ packages:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5116,10 +5039,6 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
@@ -5249,10 +5168,6 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -5938,10 +5853,6 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
@@ -6162,10 +6073,6 @@ packages:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@8.0.4:
resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6181,10 +6088,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@4.2.8:
resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==}
engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6624,10 +6527,6 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
@@ -7527,9 +7426,6 @@ packages:
'@nuxt/kit':
optional: true
unplugin@1.0.1:
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
unplugin@1.16.1:
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
engines: {node: '>=14.0.0'}
@@ -7803,13 +7699,6 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -10316,8 +10205,6 @@ snapshots:
'@sentry-internal/browser-utils': 8.48.0
'@sentry/core': 8.48.0
'@sentry/babel-plugin-component-annotate@4.6.0': {}
'@sentry/browser@8.48.0':
dependencies:
'@sentry-internal/browser-utils': 8.48.0
@@ -10326,74 +10213,8 @@ snapshots:
'@sentry-internal/replay-canvas': 8.48.0
'@sentry/core': 8.48.0
'@sentry/bundler-plugin-core@4.6.0':
dependencies:
'@babel/core': 7.27.1
'@sentry/babel-plugin-component-annotate': 4.6.0
'@sentry/cli': 2.57.0
dotenv: 16.6.1
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/cli-darwin@2.57.0':
optional: true
'@sentry/cli-linux-arm64@2.57.0':
optional: true
'@sentry/cli-linux-arm@2.57.0':
optional: true
'@sentry/cli-linux-i686@2.57.0':
optional: true
'@sentry/cli-linux-x64@2.57.0':
optional: true
'@sentry/cli-win32-arm64@2.57.0':
optional: true
'@sentry/cli-win32-i686@2.57.0':
optional: true
'@sentry/cli-win32-x64@2.57.0':
optional: true
'@sentry/cli@2.57.0':
dependencies:
https-proxy-agent: 5.0.1
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
optionalDependencies:
'@sentry/cli-darwin': 2.57.0
'@sentry/cli-linux-arm': 2.57.0
'@sentry/cli-linux-arm64': 2.57.0
'@sentry/cli-linux-i686': 2.57.0
'@sentry/cli-linux-x64': 2.57.0
'@sentry/cli-win32-arm64': 2.57.0
'@sentry/cli-win32-i686': 2.57.0
'@sentry/cli-win32-x64': 2.57.0
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/core@8.48.0': {}
'@sentry/vite-plugin@4.6.0':
dependencies:
'@sentry/bundler-plugin-core': 4.6.0
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/vue@8.48.0(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@sentry/browser': 8.48.0
@@ -11393,12 +11214,6 @@ snapshots:
address@1.2.2: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.4: {}
agentkeepalive@4.6.0:
@@ -12898,8 +12713,6 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
@@ -12994,13 +12807,6 @@ snapshots:
package-json-from-dist: 1.0.0
path-scurry: 2.0.0
glob@9.3.5:
dependencies:
fs.realpath: 1.0.0
minimatch: 8.0.4
minipass: 4.2.8
path-scurry: 1.11.1
global-directory@4.0.1:
dependencies:
ini: 4.1.1
@@ -13131,13 +12937,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -13827,10 +13626,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.8:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.4
@@ -14236,10 +14031,6 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minimatch@8.0.4:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.1:
dependencies:
brace-expansion: 2.0.2
@@ -14254,8 +14045,6 @@ snapshots:
minimist@1.2.8: {}
minipass@4.2.8: {}
minipass@7.1.2: {}
minizlib@3.0.2:
@@ -14761,8 +14550,6 @@ snapshots:
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
@@ -15902,13 +15689,6 @@ snapshots:
- rollup
- supports-color
unplugin@1.0.1:
dependencies:
acorn: 8.15.0
chokidar: 3.6.0
webpack-sources: 3.3.3
webpack-virtual-modules: 0.5.0
unplugin@1.16.1:
dependencies:
acorn: 8.15.0
@@ -16257,10 +16037,6 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-sources@3.3.3: {}
webpack-virtual-modules@0.5.0: {}
webpack-virtual-modules@0.6.2: {}
websocket-driver@0.7.4:

View File

@@ -24,7 +24,6 @@ catalog:
'@primevue/forms': ^4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^8.48.0
'@storybook/addon-docs': ^9.1.1
'@storybook/vue3': ^9.1.1

View File

@@ -21,8 +21,6 @@
@keyup.enter="blurInputElement"
@keyup.escape="cancelEditing"
@click.stop
@pointerdown.stop.capture
@pointermove.stop.capture
/>
</div>
</template>

View File

@@ -43,10 +43,8 @@ import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
@@ -63,11 +61,8 @@ const didClickBuyNow = ref(false)
const loading = ref(false)
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
await authActions.purchaseCredits(creditAmount)
await authActions.purchaseCredits(editable ? customAmount.value : amount)
loading.value = false
didClickBuyNow.value = true
}

View File

@@ -3,10 +3,8 @@
<!-- If load immediately, the top-level splitter stateKey won't be correctly
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
<template v-if="showUI" #workflow-tabs>
<TryVueNodeBanner />
<template v-if="showUI && workflowTabsPosition === 'Topbar'" #workflow-tabs>
<div
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
>
<!-- Native drag area for Electron -->
@@ -154,8 +152,6 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
const emit = defineEmits<{
ready: []
}>()

View File

@@ -143,8 +143,8 @@ onMounted(() => {
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widgetState.widget.node)
lgCanvas?.bringToFront(widgetState.widget.node)
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
}
)
})

View File

@@ -1,5 +1,5 @@
<template>
<div ref="container" class="relative h-full w-full">
<div ref="container" class="comfy-load-3d relative h-full w-full">
<LoadingOverlay ref="loadingOverlayRef" />
</div>
</template>

View File

@@ -9,7 +9,7 @@
<div ref="mainContentRef" class="relative flex-1">
<div
ref="containerRef"
class="absolute h-full w-full"
class="comfy-load-3d-viewer absolute h-full w-full"
@resize="viewer.handleResize"
/>
</div>

View File

@@ -51,7 +51,7 @@
multiple
:option-label="'display_name'"
@complete="search($event.query)"
@option-select="onAddNode($event.value)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
>
<template #option="{ option }">
@@ -78,7 +78,6 @@
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
@@ -89,7 +88,6 @@ import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
@@ -98,7 +96,6 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const telemetry = useTelemetry()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -121,14 +118,6 @@ const placeholder = computed(() => {
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Debounced search tracking (500ms as per implementation plan)
const debouncedTrackSearch = debounce((query: string) => {
if (query.trim()) {
telemetry?.trackNodeSearch({ query })
}
}, 500)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
@@ -139,22 +128,10 @@ const search = (query: string) => {
limit: searchLimit
})
]
// Track search queries with debounce
debouncedTrackSearch(query)
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
// Track node selection and emit addNode event
const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
telemetry?.trackNodeSearchResultSelected({
node_type: nodeDef.name,
last_query: currentQuery.value
})
emit('addNode', nodeDef)
}
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
inputElement ??= document.getElementById(inputId) as HTMLInputElement

View File

@@ -104,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
@@ -207,9 +206,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
disabled: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),

View File

@@ -66,7 +66,6 @@ function updateToastPosition() {
.p-toast.p-component.p-toast-top-right {
top: ${rect.top + 100}px !important;
right: ${window.innerWidth - (rect.left + rect.width) + 20}px !important;
z-index: 10000 !important;
}
`
}

View File

@@ -1,48 +0,0 @@
<template>
<Toast
group="vue-nodes-migration"
position="bottom-center"
class="w-auto"
@close="handleClose"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigration.message')
}}</span>
<Button
class="whitespace-nowrap"
size="small"
:label="t('vueNodesMigration.button')"
text
@click="handleOpenSettings"
/>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const toast = useToast()
const dialogService = useDialogService()
const isDismissed = useVueNodesMigrationDismissed()
const handleOpenSettings = () => {
dialogService.showSettingsDialog()
toast.removeGroup('vue-nodes-migration')
isDismissed.value = true
}
const handleClose = () => {
isDismissed.value = true
}
</script>

View File

@@ -37,7 +37,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -90,7 +90,7 @@
>
{{ badge.label }}
</div>
<div class="text-sm font-inter">{{ badge.text }}</div>
<div class="text-sm font-semibold">{{ badge.text }}</div>
<div v-if="badge.tooltip" class="text-xs">
{{ badge.tooltip }}
</div>
@@ -117,7 +117,7 @@
>
{{ badge.label }}
</div>
<div class="font-inter text-sm" :class="textClasses">
<div class="font-inter text-sm font-extrabold" :class="textClasses">
{{ badge.text }}
</div>
</div>

View File

@@ -1,71 +0,0 @@
<template>
<div
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center">
<i class="icon-[lucide--sparkles]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.message') }}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
>
{{ $t('vueNodesBanner.tryItOut') }}
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
unstyled
@click="handleDismiss"
>
<i class="w-5 h-5 icon-[lucide--x]"></i>
</Button>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const STORAGE_KEY = 'vueNodesBannerDismissed'
const settingStore = useSettingStore()
const bannerDismissed = useLocalStorage(STORAGE_KEY, false)
const vueNodesEnabled = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
return false
}
})
const showVueNodesBanner = computed(() => {
if (vueNodesEnabled.value) {
return false
}
if (bannerDismissed.value) {
return false
}
return true
})
const handleDismiss = (): void => {
bannerDismissed.value = true
}
const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Vue nodes:', error)
} finally {
handleDismiss()
}
}
</script>

View File

@@ -5,11 +5,7 @@ import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import {
LGraphGroup,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
@@ -99,12 +95,8 @@ export function useSelectionToolboxPosition(
} else {
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
allBounds.push([
item.pos[0],
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
item.size[0],
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
])
const bounds = item.getBounding()
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
}
}
}

View File

@@ -4,7 +4,6 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -12,7 +11,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -23,8 +21,6 @@ function useVueNodeLifecycleIndividual() {
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -79,20 +75,11 @@ function useVueNodeLifecycleIndividual() {
// Watch for Vue nodes enabled state changes
watch(
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
(enabled, wasEnabled) => {
(enabled) => {
if (enabled) {
initializeNodeManager()
ensureCorrectLayoutScale()
if (!wasEnabled && !isVueNodeToastDismissed.value) {
useToastStore().add({
group: 'vue-nodes-migration',
severity: 'info',
life: 0
})
}
} else {
comfyApp.canvas?.setDirty(true, true)
disposeNodeManagerAndSyncs()
}
},

View File

@@ -2,7 +2,6 @@ import _ from 'es-toolkit/compat'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { BadgePosition, LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -13,6 +12,7 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { adjustColor } from '@/utils/colorUtil'
/**
* Add LGraphBadge to LGraphNode based on settings.
@@ -27,7 +27,6 @@ export const useNodeBadge = () => {
const settingStore = useSettingStore()
const extensionStore = useExtensionStore()
const colorPaletteStore = useColorPaletteStore()
const priceBadge = usePriceBadge()
const nodeSourceBadgeMode = computed(
() =>
@@ -119,7 +118,29 @@ export const useNodeBadge = () => {
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
return priceBadge.getCreditsBadge(price)
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
if (hasDynamicPricing) {
@@ -141,23 +162,6 @@ export const useNodeBadge = () => {
node.badges.push(() => creditsBadge.value)
}
},
init() {
app.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
() => {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
}
)
app.canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => priceBadge.updateSubgraphCredits(e.detail.subgraphNode)
)
},
afterConfigureGraph() {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
}
})
})

View File

@@ -1,69 +0,0 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
export const usePriceBadge = () => {
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
}
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
? collectCreditsBadges(node.subgraph, visited)
: node.badges.filter((b) => isCreditsBadge(b)))
)
}
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
return (
(typeof badge === 'function' ? badge() : badge).icon?.unicode === '\ue96b'
)
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
return {
getCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -5,7 +5,10 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { tryToggleWidgetPromotion } from '@/core/graph/subgraph/proxyWidgetUtils'
import {
promoteRecommendedWidgets,
tryToggleWidgetPromotion
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { t } from '@/i18n'
import {
@@ -942,6 +945,7 @@ export function useCoreCommands(): ComfyCommand[] {
const { node } = res
canvas.select(node)
promoteRecommendedWidgets(node)
canvasStore.updateSelectedItems()
}
},

View File

@@ -1,11 +1,9 @@
import { refDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { debounce } from 'es-toolkit/compat'
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
@@ -214,38 +212,6 @@ export function useTemplateFiltering(
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {
useTelemetry()?.trackTemplateFilterChanged({
search_query: searchQuery.value || undefined,
selected_models: selectedModels.value,
selected_use_cases: selectedUseCases.value,
selected_licenses: selectedLicenses.value,
sort_by: sortBy.value,
filtered_count: filteredCount.value,
total_count: totalCount.value
})
}, 500)
// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedLicenses, sortBy],
() => {
// Only track if at least one filter is active (to avoid tracking initial state)
const hasActiveFilters =
searchQuery.value.trim() !== '' ||
selectedModels.value.length > 0 ||
selectedUseCases.value.length > 0 ||
selectedLicenses.value.length > 0 ||
sortBy.value !== 'default'
if (hasActiveFilters) {
debouncedTrackFilterChange()
}
},
{ deep: true }
)
return {
// State
searchQuery,

View File

@@ -1,8 +0,0 @@
import { createSharedComposable, useLocalStorage } from '@vueuse/core'
// Browser storage events don't fire in the same tab, so separate
// useLocalStorage() calls create isolated reactive refs. Use shared
// composable to ensure all components use the same ref instance.
export const useVueNodesMigrationDismissed = createSharedComposable(() =>
useLocalStorage('comfy.vueNodesMigration.dismissed', false)
)

View File

@@ -1,5 +1,4 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,8 +13,6 @@ export const useWorkflowTemplateSelectorDialog = () => {
}
function show() {
useTelemetry()?.trackTemplateLibraryOpened({ source: 'command' })
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,

View File

@@ -17,7 +17,6 @@ import {
matchesPropertyItem,
matchesWidgetItem,
promoteWidget,
pruneDisconnected,
widgetItemToProperty
} from '@/core/graph/subgraph/proxyWidgetUtils'
import type { WidgetItem } from '@/core/graph/subgraph/proxyWidgetUtils'
@@ -236,7 +235,6 @@ watchDebounced(
)
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()

View File

@@ -1,7 +1,4 @@
import {
demoteWidget,
promoteRecommendedWidgets
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type {
@@ -48,12 +45,9 @@ type Overlay = Partial<IBaseWidget> & {
* on the linked widget
*/
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
}
export function isDisconnectedWidget(w: ProxyWidget) {
return w instanceof disconnectedWidget.constructor
}
export function registerProxyWidgets(canvas: LGraphCanvas) {
//NOTE: canvasStore hasn't been initialized yet
@@ -68,10 +62,6 @@ export function registerProxyWidgets(canvas: LGraphCanvas) {
}
}
})
canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => promoteRecommendedWidgets(e.detail.subgraphNode)
)
SubgraphNode.prototype.onConfigure = onConfigure
}
@@ -168,11 +158,7 @@ function resolveLinkedWidget(
const { graph, nodeId, widgetName } = overlay
const n = getNodeByExecutionId(graph, nodeId)
if (!n) return [undefined, undefined]
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
//Slightly hacky. Force recursive resolution of nested widgets
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
widget.computedHeight = 20
return [n, widget]
return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)]
}
function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) {

View File

@@ -1,9 +1,5 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import {
isProxyWidget,
isDisconnectedWidget
} from '@/core/graph/subgraph/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -167,10 +163,3 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = proxyWidgets
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
.filter(isProxyWidget)
.filter((w) => !isDisconnectedWidget(w))
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
}

View File

@@ -3,13 +3,10 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -290,16 +287,6 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Load3D nodes
if (node.constructor.comfyClass !== 'Load3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Load3D') return
@@ -518,16 +505,6 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Preview3D nodes
if (node.constructor.comfyClass !== 'Preview3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { CameraManager } from './CameraManager'
@@ -22,7 +22,6 @@ import {
type MaterialMode,
type UpDirection
} from './interfaces'
import { app } from '@/scripts/app'
class Load3d {
renderer: THREE.WebGLRenderer
@@ -52,13 +51,6 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
@@ -84,7 +76,6 @@ class Load3d {
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.domElement.classList.add('flex', '!h-full', '!w-full')
container.appendChild(this.renderer.domElement)
this.eventManager = new EventManager()
@@ -173,8 +164,6 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.handleResize()
this.startAnimation()
@@ -183,72 +172,6 @@ class Load3d {
}, 100)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
const menuOptions = app.canvas.getNodeMenuOptions(this.node)
new LiteGraph.ContextMenu(menuOptions, {
event,
title: this.node.type,
extra: this.node
})
}
getEventManager(): EventManager {
return this.eventManager
}
@@ -698,11 +621,6 @@ class Load3d {
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {

View File

@@ -1,59 +0,0 @@
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import Load3d from '@/extensions/core/load3d/Load3d'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const EXPORT_FORMATS = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
] as const
/**
* Creates export menu items for a 3D node using the new extension API.
* Returns an array of context menu items including a separator and export submenu.
*/
export function createExportMenuItems(
load3d: Load3d
): (IContextMenuValue | null)[] {
return [
null, // Separator
{
content: 'Save',
has_submenu: true,
callback: (_value, _options, event, prev_menu) => {
const submenuOptions: IContextMenuValue[] = EXPORT_FORMATS.map(
(format) => ({
content: format.label,
callback: () => {
void (async () => {
try {
await load3d.exportModel(format.value)
useToastStore().add({
severity: 'success',
summary: t('toastMessages.exportSuccess', {
format: format.label
})
})
} catch (error) {
console.error('Export failed:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.label
})
)
}
})()
}
})
)
new LiteGraph.ContextMenu(submenuOptions, {
event,
parentMenu: prev_menu
})
}
}
]
}

View File

@@ -1,10 +1,7 @@
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
@@ -45,16 +42,6 @@ useExtensionService().registerExtension({
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for SaveGLB nodes
if (node.constructor.comfyClass !== 'SaveGLB') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'SaveGLB') return

View File

@@ -1710,14 +1710,6 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
bubbles: true,
detail: { subgraphNode: subgraphNode as SubgraphNode }
})
)
)
return { subgraph, node: subgraphNode as SubgraphNode }
}

View File

@@ -86,11 +86,7 @@ import {
RenderShape,
TitleMode
} from './types/globalEnums'
import type {
ClipboardItems,
ISerialisedNode,
SubgraphIO
} from './types/serialisation'
import type { ClipboardItems, SubgraphIO } from './types/serialisation'
import type { NeverNever, PickNevers } from './types/utility'
import type { IBaseWidget } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
@@ -1776,24 +1772,47 @@ export class LGraphCanvas
menu: ContextMenu,
node: LGraphNode
): void {
const canvas = LGraphCanvas.active_canvas
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
const { graph } = node
if (!graph) throw new NullGraphError()
graph.beforeChange()
// Find top-left-most boundary
let offsetX = Infinity
let offsetY = Infinity
for (const item of nodes) {
if (item.pos == null)
throw new TypeError(
'Invalid node encountered on clone. `pos` was null.'
)
if (item.pos[0] < offsetX) offsetX = item.pos[0]
if (item.pos[1] < offsetY) offsetY = item.pos[1]
const newSelected = new Set<LGraphNode>()
const fApplyMultiNode = function (
node: LGraphNode,
newNodes: Set<LGraphNode>
): void {
if (node.clonable === false) return
const newnode = node.clone()
if (!newnode) return
newnode.pos = [node.pos[0] + 5, node.pos[1] + 5]
if (!node.graph) throw new NullGraphError()
node.graph.add(newnode)
newNodes.add(newnode)
}
canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5]
})
const canvas = LGraphCanvas.active_canvas
if (
!canvas.selected_nodes ||
Object.keys(canvas.selected_nodes).length <= 1
) {
fApplyMultiNode(node, newSelected)
} else {
for (const i in canvas.selected_nodes) {
fApplyMultiNode(canvas.selected_nodes[i], newSelected)
}
}
if (newSelected.size) {
canvas.selectNodes([...newSelected])
}
graph.afterChange()
canvas.setDirty(true, true)
}
/**
@@ -2365,22 +2384,42 @@ export class LGraphCanvas
node &&
this.allow_interaction
) {
const items = this._deserializeItems(this._serializeItems([node]), {
position: node.pos
})
const cloned = items?.created[0] as LGraphNode | undefined
if (!cloned) return
let newType = node.type
cloned.pos[0] += 5
cloned.pos[1] += 5
if (node instanceof SubgraphNode) {
const cloned = node.subgraph.clone().asSerialisable()
if (this.allow_dragnodes) {
pointer.onDragStart = (pointer) => {
this.#startDraggingItems(cloned, pointer)
}
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
const subgraph = graph.createSubgraph(cloned)
subgraph.configure(cloned)
newType = subgraph.id
}
const node_data = node.clone()?.serialize()
if (node_data?.type != null) {
// Ensure the cloned node is configured against the correct type (especially for SubgraphNodes)
node_data.type = newType
const cloned = LiteGraph.createNode(newType)
if (cloned) {
cloned.configure(node_data)
cloned.pos[0] += 5
cloned.pos[1] += 5
if (this.allow_dragnodes) {
pointer.onDragStart = (pointer) => {
graph.add(cloned, false)
this.#startDraggingItems(cloned, pointer)
}
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
} else {
// TODO: Check if before/after change are necessary here.
graph.beforeChange()
graph.add(cloned, false)
graph.afterChange()
}
return
}
}
return
}
// Node clicked
@@ -2672,7 +2711,7 @@ export class LGraphCanvas
): boolean {
const outputLinks = [
...(output.links ?? []),
...(output._floatingLinks ?? new Set())
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
@@ -3924,26 +3963,17 @@ export class LGraphCanvas
const { created, nodes, links, reroutes } = results
// const failedNodes: ISerialisedNode[] = []
const subgraphIdMap: Record<string, string> = {}
// SubgraphV2: Remove always-clone behaviour
//Update subgraph ids
for (const subgraphInfo of parsed.subgraphs)
subgraphInfo.id = subgraphIdMap[subgraphInfo.id] = createUuidv4()
const allNodeInfo: ISerialisedNode[] = [
parsed.nodes ? [parsed.nodes] : [],
parsed.subgraphs ? parsed.subgraphs.map((s) => s.nodes ?? []) : []
].flat(2)
for (const nodeInfo of allNodeInfo)
if (nodeInfo.type in subgraphIdMap)
nodeInfo.type = subgraphIdMap[nodeInfo.type]
// Subgraphs
for (const info of parsed.subgraphs) {
// SubgraphV2: Remove always-clone behaviour
const originalId = info.id
info.id = createUuidv4()
const subgraph = graph.createSubgraph(info)
results.subgraphs.set(info.id, subgraph)
subgraph.configure(info)
results.subgraphs.set(originalId, subgraph)
}
for (const info of parsed.subgraphs)
results.subgraphs.get(info.id)?.configure(info)
// Groups
for (const info of parsed.groups) {
@@ -3955,6 +3985,17 @@ export class LGraphCanvas
created.push(group)
}
// Update subgraph ids with nesting
function updateSubgraphIds(nodes: { type: string }[]) {
for (const info of nodes) {
const subgraph = results.subgraphs.get(info.type)
if (!subgraph) continue
info.type = subgraph.id
updateSubgraphIds(subgraph.nodes)
}
}
updateSubgraphIds(parsed.nodes)
// Nodes
for (const info of parsed.nodes) {
const node = info.type == null ? null : LiteGraph.createNode(info.type)

View File

@@ -21,11 +21,6 @@ export interface LGraphCanvasEventMap {
fromNode: SubgraphNode
}
/** Dispatched after a group of items has been converted to a subgraph*/
'subgraph-converted': {
subgraphNode: SubgraphNode
}
'litegraph:canvas':
| { subType: 'before-change' | 'after-change' }
| {

View File

@@ -546,7 +546,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Clean up all promoted widgets
for (const widget of this.widgets) {
if ('isProxyWidget' in widget && widget.isProxyWidget) continue
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
@@ -619,17 +618,4 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Call parent serialize method
return super.serialize()
}
override clone() {
const clone = super.clone()
// force reasign so domWidgets reset ownership
// eslint-disable-next-line no-self-assign
this.properties.proxyWidgets = this.properties.proxyWidgets
//TODO: Consider deep cloning subgraphs here.
//It's the safest place to prevent creation of linked subgraphs
//But the frequency of clone().serialize() calls is likely to result in
//pollution of rootGraph.subgraphs
return clone
}
}

View File

@@ -140,7 +140,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
displayValue,
// @ts-expect-error Prevent naming conflicts with custom nodes.
labelBaseline,
promoted,
...safeValues
} = widget

View File

@@ -305,10 +305,6 @@
"Workspace_ToggleFocusMode": {
"label": "Toggle Focus Mode"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Toggle Assets Sidebar",
"tooltip": "Assets"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Toggle Model Library Sidebar",
"tooltip": "Model Library"

View File

@@ -990,7 +990,6 @@
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Focus Mode": "Focus Mode",
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
@@ -1057,8 +1056,7 @@
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"VueNodes": "Vue Nodes"
"PlanCredits": "Plan & Credits"
},
"serverConfigItems": {
"listen": {
@@ -1474,7 +1472,6 @@
"failedToApplyTexture": "Failed to apply texture",
"no3dSceneToExport": "No 3D scene to export",
"failedToExportModel": "Failed to export model as {format}",
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"interrupted": "Execution has been interrupted",
@@ -1656,24 +1653,18 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"apiNodesBalance": "\"API Nodes\" Credit Balance",
"apiNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
"viewUsageHistory": "View usage history",
"addApiCredits": "Add API credits",
"addCredits": "Add credits",
"monthlyCreditsRollover": "These credits will rollover to the next month",
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Credits purchased separately that don't expire",
"nextBillingCycle": "next billing cycle",
"yourPlanIncludes": "Your plan includes:",
"viewMoreDetails": "View more details",
"learnMore": "Learn more",
"messageSupport": "Message support",
"invoiceHistory": "Invoice history",
"benefits": {
"benefit1": "Monthly credits for Partner Nodes — top up when needed",
"benefit1": "$10 in monthly credits for API models — top up when needed",
"benefit2": "Up to 30 min runtime per job"
},
"required": {
@@ -1821,13 +1812,5 @@
"Close": "Close"
}
}
},
"vueNodesMigration": {
"message": "Prefer the classic node design?",
"button": "Open Settings"
},
"vueNodesBanner": {
"message": "Nodes just got a new look and feel",
"tryItOut": "Try it out"
}
}

View File

@@ -5158,7 +5158,7 @@
},
"LumaConceptsNode": {
"display_name": "Luma Concepts",
"description": "Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
"description": "Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
"inputs": {
"concept1": {
"name": "concept1"
@@ -5462,7 +5462,7 @@
},
"MinimaxImageToVideoNode": {
"display_name": "MiniMax Image to Video",
"description": "Generates videos synchronously based on an image and prompt, and optional parameters.",
"description": "Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.",
"inputs": {
"image": {
"name": "image",
@@ -5492,7 +5492,7 @@
},
"MinimaxTextToVideoNode": {
"display_name": "MiniMax Text to Video",
"description": "Generates videos synchronously based on a prompt, and optional parameters.",
"description": "Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.",
"inputs": {
"prompt_text": {
"name": "prompt_text",
@@ -9027,8 +9027,7 @@
},
"outputs": {
"0": {
"name": "recraft_color",
"tooltip": null
"name": "recraft_color"
}
}
},
@@ -9045,8 +9044,7 @@
},
"outputs": {
"0": {
"name": "recraft_controls",
"tooltip": null
"name": "recraft_controls"
}
}
},
@@ -9057,11 +9055,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftCrispUpscaleNode": {
@@ -9071,11 +9064,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftImageInpaintingNode": {
@@ -9110,11 +9098,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftImageToImageNode": {
@@ -9154,11 +9137,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftRemoveBackgroundNode": {
@@ -9168,14 +9146,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"RecraftReplaceBackgroundNode": {
@@ -9207,11 +9177,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftStyleV3DigitalIllustration": {
@@ -9224,8 +9189,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9240,8 +9204,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9255,8 +9218,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9270,8 +9232,7 @@
},
"outputs": {
"0": {
"name": "recraft_style",
"tooltip": null
"name": "recraft_style"
}
}
},
@@ -9309,11 +9270,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftTextToVectorNode": {
@@ -9350,11 +9306,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"RecraftVectorizeImageNode": {
@@ -9364,11 +9315,6 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ReferenceLatent": {
@@ -10295,38 +10241,6 @@
}
}
},
"ScaleROPE": {
"display_name": "ScaleROPE",
"description": "Scale and shift the ROPE of the model.",
"inputs": {
"model": {
"name": "model"
},
"scale_x": {
"name": "scale_x"
},
"shift_x": {
"name": "shift_x"
},
"scale_y": {
"name": "scale_y"
},
"shift_y": {
"name": "shift_y"
},
"scale_t": {
"name": "scale_t"
},
"shift_t": {
"name": "shift_t"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SD_4XUpscale_Conditioning": {
"display_name": "SD_4XUpscale_Conditioning",
"inputs": {

View File

@@ -364,14 +364,6 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Vue nodes)",
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Vue Nodes)",
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
},
"Comfy_WidgetControlMode": {
"name": "Widget control mode",
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",

View File

@@ -1,3 +1,5 @@
import { createSingletonPromise } from '@vueuse/core'
import { api } from '@/scripts/api'
import { isCloud } from '@/platform/distribution/types'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -12,18 +14,86 @@ export const useSessionCookie = () => {
* Called after login and on token refresh.
*/
const createSession = async (): Promise<void> => {
if (!isCloud || logoutInProgress) return
const promise = createSessionSingleton()
inFlightCreateSession = promise
try {
await promise
} finally {
if (inFlightCreateSession === promise) {
inFlightCreateSession = null
}
}
}
/**
* Deletes the session cookie.
* Called on logout.
*/
const deleteSession = async (): Promise<void> => {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
logoutInProgress = true
if (!authHeader) {
throw new Error('No auth header available for session creation')
try {
if (inFlightCreateSession) {
currentCreateController?.abort()
try {
await inFlightCreateSession
} catch (error: unknown) {
if (!isAbortError(error)) {
throw error
}
}
}
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${
errorData.message || response.statusText
}`
)
}
} finally {
logoutInProgress = false
await createSessionSingleton.reset()
}
}
return {
createSession,
deleteSession
}
}
let inFlightCreateSession: Promise<void> | null = null
let currentCreateController: AbortController | null = null
let logoutInProgress = false
const createSessionSingleton = createSingletonPromise(async () => {
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
if (!authHeader) {
throw new Error('No auth header available for session creation')
}
const controller = new AbortController()
currentCreateController = controller
try {
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
signal: controller.signal,
headers: {
...authHeader,
'Content-Type': 'application/json'
@@ -36,30 +106,19 @@ export const useSessionCookie = () => {
`Failed to create session: ${errorData.message || response.statusText}`
)
}
}
/**
* Deletes the session cookie.
* Called on logout.
*/
const deleteSession = async (): Promise<void> => {
if (!isCloud) return
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${errorData.message || response.statusText}`
)
} catch (error: unknown) {
if (!isAbortError(error)) {
throw error
}
} finally {
if (currentCreateController === controller) {
currentCreateController = null
}
}
})
return {
createSession,
deleteSession
}
const isAbortError = (error: unknown): boolean => {
if (!error || typeof error !== 'object') return false
const name = 'name' in error ? (error as { name?: string }).name : undefined
return name === 'AbortError'
}

View File

@@ -37,7 +37,6 @@ const emit = defineEmits<{
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
@@ -63,7 +62,6 @@ const startPollingSubscriptionStatus = () => {
if (isActiveSubscription.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')
}
} catch (error) {

View File

@@ -1,15 +1,15 @@
<template>
<div class="flex flex-col items-start gap-1 self-stretch">
<div class="flex flex-col gap-4">
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $t('subscription.benefits.benefit1') }}
</span>
</div>
<div class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-xs text-text-primary" />
<span class="text-sm text-text-primary">
<div class="flex items-start gap-2 pb-2">
<i class="pi pi-check mt-1 text-sm" />
<span class="text-sm">
{{ $t('subscription.benefits.benefit2') }}
</span>
</div>
@@ -19,15 +19,8 @@
text
icon="pi pi-external-link"
icon-pos="left"
class="flex h-8 min-h-6 py-2 px-0 items-center gap-2 rounded text-text-secondary"
:pt="{
icon: {
class: 'text-xs text-text-secondary'
},
label: {
class: 'text-sm text-text-secondary'
}
}"
size="small"
class="self-start !p-0 text-sm hover:!bg-transparent [&]:!text-[inherit]"
@click="handleViewMoreDetails"
/>
</div>

View File

@@ -1,10 +1,10 @@
<template>
<TabPanel value="PlanCredits" class="subscription-container h-full">
<div class="flex h-full flex-col gap-6">
<div class="flex items-baseline gap-2">
<span class="text-2xl font-inter font-semibold leading-tight">
<div class="flex h-full flex-col">
<div class="flex items-center gap-2">
<h2 class="text-2xl">
{{ $t('subscription.title') }}
</span>
</h2>
<CloudBadge
reverse-order
background-color="var(--p-dialog-background)"
@@ -12,20 +12,17 @@
</div>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div class="rounded-lg border border-charcoal-400 p-4">
<div>
<div class="flex items-center justify-between">
<div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">{{ formattedMonthlyPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold">{{
formattedMonthlyPrice
}}</span>
<span>{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<div v-if="isActiveSubscription" class="text-xs text-muted">
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
@@ -46,15 +43,7 @@
v-if="isActiveSubscription"
:label="$t('subscription.manageSubscription')"
severity="secondary"
class="text-xs bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px; padding: 8px 16px;'
},
label: {
class: 'text-text-primary'
}
}"
class="text-xs"
@click="manageSubscription"
/>
<SubscribeButton
@@ -67,143 +56,92 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 pt-9 lg:grid-cols-2">
<div class="flex flex-col flex-1">
<div class="grid grid-cols-1 gap-6 rounded-lg pt-10 lg:grid-cols-2">
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="text-sm">
{{ $t('subscription.partnerNodesBalance') }}
{{ $t('subscription.apiNodesBalance') }}
</div>
<div class="flex items-center">
<div class="text-sm text-muted">
{{ $t('subscription.partnerNodesDescription') }}
<div class="text-xs text-muted">
{{ $t('subscription.apiNodesDescription') }}
</div>
</div>
</div>
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-smoke-100 dark-theme:bg-charcoal-600'
)
"
class="flex flex-col gap-3 rounded-lg border p-4 dark-theme:border-0 dark-theme:bg-charcoal-600"
>
<Button
v-tooltip="refreshTooltip"
icon="pi pi-sync"
text
size="small"
class="absolute top-0.5 right-0"
:loading="isLoadingBalance"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
},
loadingIcon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleRefresh"
/>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-secondary">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
${{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<div class="flex flex-col gap-1">
<div class="flex items-center gap-4">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ monthlyBonusCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.monthlyBonusDescription') }}
</div>
<Button
v-tooltip="$t('subscription.monthlyCreditsRollover')"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
<div class="flex items-center gap-4">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<div v-else class="text-sm text-text-secondary font-bold">
${{ prepaidCredits }}
</div>
<div class="flex items-center gap-1">
<div class="text-sm text-text-secondary">
{{ $t('subscription.prepaidDescription') }}
</div>
<Button
v-tooltip="$t('subscription.prepaidCreditsInfo')"
icon="pi pi-question-circle"
text
rounded
size="small"
class="h-4 w-4"
:pt="{
icon: {
class: 'text-text-secondary text-xs'
}
}"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-text-secondary underline hover:text-text-secondary"
style="text-decoration: underline"
<div>
<div class="text-xs text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<div class="text-2xl font-bold">${{ totalCredits }}</div>
</div>
<Button
icon="pi pi-sync"
severity="secondary"
size="small"
:loading="isLoadingBalance"
@click="handleRefresh"
/>
</div>
<div
v-if="latestEvents.length > 0"
class="flex flex-col gap-2 pt-3 text-xs"
>
<div
v-for="event in latestEvents"
:key="event.event_id"
class="flex items-center justify-between py-1"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<div class="flex flex-col gap-0.5">
<span class="font-medium">
{{
event.event_type
? customerEventService.formatEventType(
event.event_type
)
: ''
}}
</span>
<span class="text-muted">
{{
event.createdAt
? customerEventService.formatDate(event.createdAt)
: ''
}}
</span>
</div>
<div
v-if="event.params?.amount !== undefined"
class="font-bold"
>
${{
customerEventService.formatAmount(
event.params.amount as number
)
}}
</div>
</div>
</div>
<div class="flex items-center justify-between pt-2">
<Button
:label="$t('subscription.viewUsageHistory')"
text
severity="secondary"
class="p-0 text-xs text-muted"
@click="handleViewUsageHistory"
/>
<Button
v-if="isActiveSubscription"
:label="$t('subscription.addCredits')"
:label="$t('subscription.addApiCredits')"
severity="secondary"
class="p-2 min-h-8 bg-interface-menu-component-surface-selected"
:pt="{
root: {
style: 'border-radius: 8px;'
},
label: {
class: 'text-sm'
}
}"
class="text-xs"
@click="handleAddApiCredits"
/>
</div>
@@ -211,7 +149,7 @@
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex flex-col gap-3">
<div class="text-sm">
{{ $t('subscription.yourPlanIncludes') }}
</div>
@@ -223,7 +161,7 @@
</div>
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
class="flex items-center justify-between border-t border-charcoal-400 pt-3"
>
<div class="flex gap-2">
<Button
@@ -232,15 +170,7 @@
severity="secondary"
icon="pi pi-question-circle"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleLearnMoreClick"
@click="handleLearnMore"
/>
<Button
:label="$t('subscription.messageSupport')"
@@ -248,15 +178,6 @@
severity="secondary"
icon="pi pi-comment"
class="text-xs"
:loading="isLoadingSupport"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleMessageSupport"
/>
</div>
@@ -268,14 +189,6 @@
icon="pi pi-external-link"
icon-pos="right"
class="text-xs"
:pt="{
label: {
class: 'text-text-secondary'
},
icon: {
class: 'text-text-secondary text-xs'
}
}"
@click="handleInvoiceHistory"
/>
</div>
@@ -285,16 +198,26 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onMounted, ref } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { cn } from '@/utils/tailwindUtil'
import type { AuditLog } from '@/services/customerEventsService'
import { useCustomerEventsService } from '@/services/customerEventsService'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const authStore = useFirebaseAuthStore()
const customerEventService = useCustomerEventsService()
const {
isActiveSubscription,
@@ -303,20 +226,54 @@ const {
formattedEndDate,
formattedMonthlyPrice,
manageSubscription,
handleInvoiceHistory
handleViewUsageHistory,
handleLearnMore,
handleInvoiceHistory,
fetchStatus
} = useSubscription()
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const latestEvents = ref<AuditLog[]>([])
const {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
const totalCredits = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
const fetchLatestEvents = async () => {
try {
const response = await customerEventService.getMyEvents({
page: 1,
limit: 2
})
if (response?.events) {
latestEvents.value = response.events
}
} catch (error) {
console.error('[SubscriptionPanel] Error fetching latest events:', error)
}
}
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
await commandStore.execute('Comfy.ContactSupport')
}
const handleRefresh = async () => {
await Promise.all([
authActions.fetchBalance(),
fetchStatus(),
fetchLatestEvents()
])
}
</script>
<style scoped>

View File

@@ -1,5 +1,4 @@
import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
@@ -26,14 +25,17 @@ type CloudSubscriptionStatusResponse = {
end_date?: string | null
}
function useSubscriptionInternal() {
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
const isSubscribedOrIsNotCloud = computed(() => {
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
return subscriptionStatus.value?.is_active ?? false
})
return subscriptionStatus.value?.is_active ?? false
})
let isWatchSetup = false
export function useSubscription() {
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const dialogService = useDialogService()
@@ -159,17 +161,20 @@ function useSubscriptionInternal() {
return statusData
}
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
if (!isWatchSetup) {
isWatchSetup = true
watch(
() => isLoggedIn.value,
async (loggedIn) => {
if (loggedIn) {
await fetchSubscriptionStatus()
} else {
subscriptionStatus.value = null
}
},
{ immediate: true }
)
}
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
@@ -222,5 +227,3 @@ function useSubscriptionInternal() {
handleInvoiceHistory
}
}
export const useSubscription = createSharedComposable(useSubscriptionInternal)

View File

@@ -1,66 +0,0 @@
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const { t } = useI18n()
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const { fetchStatus, formattedRenewalDate } = useSubscription()
const isLoadingSupport = ref(false)
const refreshTooltip = computed(() => {
const date =
formattedRenewalDate.value || t('subscription.nextBillingCycle')
return `Refreshes on ${date}`
})
onMounted(() => {
void handleRefresh()
})
const handleAddApiCredits = () => {
dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {
isLoadingSupport.value = false
}
}
const handleRefresh = async () => {
try {
await Promise.all([authActions.fetchBalance(), fetchStatus()])
} catch (error) {
console.error('[useSubscriptionActions] Error refreshing data:', error)
}
}
const handleLearnMoreClick = () => {
window.open('https://docs.comfy.org/get_started/cloud', '_blank')
}
return {
isLoadingSupport,
refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
}
}

View File

@@ -1,61 +0,0 @@
import { computed } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
const totalCredits = computed(() => {
if (!authStore.balance?.amount_micros) return '0.00'
try {
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting total credits:',
error
)
return '0.00'
}
})
const monthlyBonusCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.cloud_credit_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.cloud_credit_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting monthly bonus credits:',
error
)
return '0.00'
}
})
const prepaidCredits = computed(() => {
const balance = authStore.balance as any
if (!balance?.prepaid_balance_micros) return '0.00'
try {
return formatMetronomeCurrency(balance.prepaid_balance_micros, 'usd')
} catch (error) {
console.error(
'[useSubscriptionCredits] Error formatting prepaid credits:',
error
)
return '0.00'
}
})
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
isLoadingBalance
}
}

View File

@@ -13,6 +13,11 @@
import { isCloud } from '@/platform/distribution/types'
import type { TaskItem } from '@/schemas/apiSchema'
interface ReconciliationResult {
/** All items to display, sorted by queueIndex descending (newest first) */
items: TaskItem[]
}
/**
* V1 reconciliation: QueueIndex-based filtering works because V1 has stable,
* monotonically increasing queue indices.
@@ -20,15 +25,13 @@ import type { TaskItem } from '@/schemas/apiSchema'
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV1(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex: number | undefined
): TaskItem[] {
): ReconciliationResult {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -50,9 +53,13 @@ function reconcileHistoryV1(
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
return [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
const allItems = [...itemsAddedSinceLastSync, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -62,14 +69,12 @@ function reconcileHistoryV1(
* Sort order: Sorts serverHistory by queueIndex descending (newest first) to ensure
* consistent ordering. JavaScript .filter() maintains iteration order, so filtered
* results remain sorted. clientHistory is assumed already sorted from previous update.
*
* @returns All items to display, sorted by queueIndex descending (newest first)
*/
function reconcileHistoryV2(
serverHistory: TaskItem[],
clientHistory: TaskItem[],
maxItems: number
): TaskItem[] {
): ReconciliationResult {
const sortedServerHistory = serverHistory.sort(
(a, b) => b.prompt[0] - a.prompt[0]
)
@@ -79,18 +84,29 @@ function reconcileHistoryV2(
)
const clientPromptIds = new Set(clientHistory.map((item) => item.prompt[1]))
const newItems = sortedServerHistory.filter(
(item) => !clientPromptIds.has(item.prompt[1])
const newPromptIds = new Set(
[...serverPromptIds].filter((id) => !clientPromptIds.has(id))
)
const newItems = sortedServerHistory.filter((item) =>
newPromptIds.has(item.prompt[1])
)
const retainedPromptIds = new Set(
[...serverPromptIds].filter((id) => clientPromptIds.has(id))
)
const clientItemsStillOnServer = clientHistory.filter((item) =>
serverPromptIds.has(item.prompt[1])
retainedPromptIds.has(item.prompt[1])
)
// Merge new and reused items, sort by queueIndex descending, limit to maxItems
return [...newItems, ...clientItemsStillOnServer]
const allItems = [...newItems, ...clientItemsStillOnServer]
.sort((a, b) => b.prompt[0] - a.prompt[0])
.slice(0, maxItems)
return {
items: allItems
}
}
/**
@@ -109,7 +125,7 @@ export function reconcileHistory(
clientHistory: TaskItem[],
maxItems: number,
lastKnownQueueIndex?: number
): TaskItem[] {
): ReconciliationResult {
if (isCloud) {
return reconcileHistoryV2(serverHistory, clientHistory, maxItems)
}

View File

@@ -1057,17 +1057,17 @@ export const CORE_SETTINGS: SettingParams[] = [
*/
{
id: 'Comfy.VueNodes.Enabled',
name: 'Modern Node Design (Vue Nodes)',
type: 'boolean',
name: 'Enable Vue node rendering (hidden)',
type: 'hidden',
tooltip:
'Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering.',
'Render nodes as Vue components instead of canvas. Hidden; toggle via Experimental keybinding.',
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
name: 'Auto-scale layout (Vue nodes)',
name: 'Auto-scale layout for Vue nodes',
tooltip:
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
type: 'boolean',

View File

@@ -3,31 +3,18 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
WorkflowImportMetadata
TemplateMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -136,10 +123,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
@@ -149,20 +132,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
const executionContext = this.getExecutionContext()
@@ -184,21 +153,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
// Apply normalization to survey responses
const normalizedResponses = responses
? normalizeSurveyResponses(responses)
: undefined
this.trackEvent(eventName, normalizedResponses)
// If this is a survey submission, also set user properties with normalized data
if (stage === 'submitted' && normalizedResponses && this.mixpanel) {
try {
this.mixpanel.people.set(normalizedResponses)
} catch (error) {
console.error('Failed to set survey user properties:', error)
}
}
this.trackEvent(eventName, responses)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
@@ -223,34 +178,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
@@ -267,28 +194,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
const nodeMetrics = reduceAllNodes(
app.graph,
(acc, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
return {
custom_node_count: acc.custom_node_count + (isCustomNode ? 1 : 0),
api_node_count: acc.api_node_count + (isApiNode ? 1 : 0),
subgraph_count: acc.subgraph_count + (isSubgraph ? 1 : 0)
}
},
{ custom_node_count: 0, api_node_count: 0, subgraph_count: 0 }
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
@@ -311,22 +218,19 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeMetrics
template_license: englishMetadata?.license ?? template?.license
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeMetrics
workflow_name: activeWorkflow.filename
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeMetrics
workflow_name: undefined
}
}
}

View File

@@ -57,10 +57,6 @@ export interface ExecutionContext {
template_models?: string[]
template_use_case?: string
template_license?: string
// Node composition metrics
custom_node_count: number
api_node_count: number
subgraph_count: number
}
/**
@@ -93,87 +89,15 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
}
/**
* Template library metadata
*/
export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
}
/**
* Page visibility metadata
*/
export interface PageVisibilityMetadata {
visibility_state: 'visible' | 'hidden'
}
/**
* Tab count metadata
*/
export interface TabCountMetadata {
tab_count: number
}
/**
* Node search metadata
*/
export interface NodeSearchMetadata {
query: string
}
/**
* Node search result selection metadata
*/
export interface NodeSearchResultMetadata {
node_type: string
last_query: string
}
/**
* Template filter tracking metadata
*/
export interface TemplateFilterMetadata {
search_query?: string
selected_models: string[]
selected_use_cases: string[]
selected_licenses: string[]
sort_by:
| 'default'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
filtered_count: number
total_count: number
}
/**
* Core telemetry provider interface
*/
export interface TelemetryProvider {
// Authentication flow events
trackAuth(metadata: AuthMetadata): void
trackUserLoggedIn(): void
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
@@ -184,23 +108,6 @@ export interface TelemetryProvider {
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
// Workflow management events
trackWorkflowImported(metadata: WorkflowImportMetadata): void
// Page visibility events
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
// Tab tracking events
trackTabCount(metadata: TabCountMetadata): void
// Node search analytics events
trackNodeSearch(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
// Template filter tracking events
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
@@ -218,15 +125,11 @@ export interface TelemetryProvider {
export const TelemetryEvents = {
// Authentication Flow
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_LOGGED_IN: 'app:user_logged_in',
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
@@ -239,23 +142,6 @@ export const TelemetryEvents = {
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -277,11 +163,3 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| PageVisibilityMetadata
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| TemplateFilterMetadata

View File

@@ -1,683 +0,0 @@
/**
* Unit tests for survey normalization utilities
* Uses real example data from migration script to verify categorization accuracy
*/
import { describe, expect, it } from 'vitest'
import {
normalizeIndustry,
normalizeUseCase,
normalizeSurveyResponses
} from '../surveyNormalization'
describe('normalizeIndustry', () => {
describe('Film / TV / Animation category', () => {
it('should categorize film and television production', () => {
expect(normalizeIndustry('Film and television production')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('film')).toBe('Film / TV / Animation')
expect(normalizeIndustry('TV production')).toBe('Film / TV / Animation')
expect(normalizeIndustry('animation studio')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('VFX artist')).toBe('Film / TV / Animation')
expect(normalizeIndustry('cinema')).toBe('Film / TV / Animation')
expect(normalizeIndustry('documentary filmmaker')).toBe(
'Film / TV / Animation'
)
})
it('should handle typos and variations', () => {
expect(normalizeIndustry('animtion')).toBe('Film / TV / Animation')
expect(normalizeIndustry('film prod')).toBe('Film / TV / Animation')
expect(normalizeIndustry('movie production')).toBe(
'Film / TV / Animation'
)
})
})
describe('Marketing / Advertising / Social Media category', () => {
it('should categorize marketing and social media', () => {
expect(normalizeIndustry('Marketing & Social Media')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('digital marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('YouTube content creation')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('TikTok marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('brand promotion')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('influencer marketing')).toBe(
'Marketing / Advertising / Social Media'
)
})
it('should handle social media variations', () => {
expect(normalizeIndustry('social content')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('content creation')).toBe(
'Marketing / Advertising / Social Media'
)
})
})
describe('Software / IT / AI category', () => {
it('should categorize software development', () => {
expect(normalizeIndustry('Software Development')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('tech startup')).toBe('Software / IT / AI')
expect(normalizeIndustry('AI research')).toBe('Software / IT / AI')
expect(normalizeIndustry('web development')).toBe('Software / IT / AI')
expect(normalizeIndustry('machine learning')).toBe('Software / IT / AI')
expect(normalizeIndustry('data science')).toBe('Software / IT / AI')
expect(normalizeIndustry('programming')).toBe('Software / IT / AI')
})
it('should handle IT variations', () => {
expect(normalizeIndustry('software engineer')).toBe('Software / IT / AI')
expect(normalizeIndustry('app developer')).toBe('Software / IT / AI')
})
it('should categorize corporate AI research', () => {
expect(normalizeIndustry('corporate AI research')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('AI research lab')).toBe('Software / IT / AI')
expect(normalizeIndustry('tech company AI research')).toBe(
'Software / IT / AI'
)
})
})
describe('Gaming / Interactive Media category', () => {
it('should categorize gaming industry', () => {
expect(normalizeIndustry('Indie Game Studio')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('game development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('VR development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('interactive media')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('metaverse')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('Unity developer')).toBe(
'Gaming / Interactive Media'
)
})
it('should handle game dev variations', () => {
expect(normalizeIndustry('game dev')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('indie games')).toBe(
'Gaming / Interactive Media'
)
})
})
describe('Architecture / Engineering / Construction category', () => {
it('should categorize architecture and construction', () => {
expect(normalizeIndustry('Architecture firm')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('construction')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('civil engineering')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('interior design')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('landscape architecture')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('real estate')).toBe(
'Architecture / Engineering / Construction'
)
})
})
describe('Fashion / Beauty / Retail category', () => {
it('should categorize fashion and beauty', () => {
expect(normalizeIndustry('Custom Jewelry Design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('fashion design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('beauty industry')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('retail store')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('cosmetics')).toBe('Fashion / Beauty / Retail')
})
})
describe('Healthcare / Medical / Life Science category', () => {
it('should categorize medical and health fields', () => {
expect(normalizeIndustry('Medical Research')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('healthcare')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('biotech')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('pharmaceutical')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('clinical research')).toBe(
'Healthcare / Medical / Life Science'
)
})
})
describe('Education / Research category', () => {
it('should categorize education and research', () => {
expect(normalizeIndustry('university research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('academic')).toBe('Education / Research')
expect(normalizeIndustry('teaching')).toBe('Education / Research')
expect(normalizeIndustry('student')).toBe('Education / Research')
expect(normalizeIndustry('professor')).toBe('Education / Research')
})
it('should categorize academic AI research', () => {
expect(normalizeIndustry('academic AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('university AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('AI research at university')).toBe(
'Education / Research'
)
})
})
describe('Fine Art / Contemporary Art category', () => {
it('should categorize art fields', () => {
expect(normalizeIndustry('fine art')).toBe('Fine Art / Contemporary Art')
expect(normalizeIndustry('contemporary artist')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('digital art')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('illustration')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('gallery')).toBe('Fine Art / Contemporary Art')
})
})
describe('Photography / Videography category', () => {
it('should categorize photography fields', () => {
expect(normalizeIndustry('photography')).toBe('Photography / Videography')
expect(normalizeIndustry('wedding photography')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('commercial photo')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('videography')).toBe('Photography / Videography')
})
})
describe('Product & Industrial Design category', () => {
it('should categorize product design', () => {
expect(normalizeIndustry('product design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('industrial design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('manufacturing')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('3d rendering')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('automotive design')).toBe(
'Product & Industrial Design'
)
})
})
describe('Music / Performing Arts category', () => {
it('should categorize music and performing arts', () => {
expect(normalizeIndustry('music production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('theater')).toBe('Music / Performing Arts')
expect(normalizeIndustry('concert production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('live events')).toBe('Music / Performing Arts')
})
})
describe('E-commerce / Print-on-Demand / Business category', () => {
it('should categorize business fields', () => {
expect(normalizeIndustry('ecommerce')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('print on demand')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('startup')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('online store')).toBe(
'E-commerce / Print-on-Demand / Business'
)
})
})
describe('Nonprofit / Government / Public Sector category', () => {
it('should categorize nonprofit and government', () => {
expect(normalizeIndustry('nonprofit')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('government agency')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('public service')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('charity')).toBe(
'Nonprofit / Government / Public Sector'
)
})
})
describe('Adult / NSFW category', () => {
it('should categorize adult content', () => {
expect(normalizeIndustry('adult entertainment')).toBe('Adult / NSFW')
expect(normalizeIndustry('NSFW content')).toBe('Adult / NSFW')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeIndustry('other')).toBe('Other / Undefined')
expect(normalizeIndustry('none')).toBe('Other / Undefined')
expect(normalizeIndustry('undefined')).toBe('Other / Undefined')
expect(normalizeIndustry('unknown')).toBe('Other / Undefined')
expect(normalizeIndustry('n/a')).toBe('Other / Undefined')
expect(normalizeIndustry('not applicable')).toBe('Other / Undefined')
expect(normalizeIndustry('-')).toBe('Other / Undefined')
expect(normalizeIndustry('')).toBe('Other / Undefined')
})
it('should handle null and invalid inputs', () => {
expect(normalizeIndustry(null as any)).toBe('Other / Undefined')
expect(normalizeIndustry(undefined as any)).toBe('Other / Undefined')
expect(normalizeIndustry(123 as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown creative fields with prefix', () => {
expect(normalizeIndustry('Unknown Creative Field')).toBe(
'Uncategorized: Unknown Creative Field'
)
expect(normalizeIndustry('Completely Novel Field')).toBe(
'Uncategorized: Completely Novel Field'
)
})
})
})
describe('normalizeUseCase', () => {
describe('Content Creation & Marketing', () => {
it('should categorize content creation', () => {
expect(normalizeUseCase('YouTube thumbnail generation')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('social media content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('marketing campaigns')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('TikTok content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('brand content creation')).toBe(
'Content Creation & Marketing'
)
})
})
describe('Art & Illustration', () => {
it('should categorize art and illustration', () => {
expect(normalizeUseCase('Creating concept art for movies')).toBe(
'Art & Illustration'
)
expect(normalizeUseCase('digital art')).toBe('Art & Illustration')
expect(normalizeUseCase('character design')).toBe('Art & Illustration')
expect(normalizeUseCase('illustration work')).toBe('Art & Illustration')
expect(normalizeUseCase('fantasy art')).toBe('Art & Illustration')
})
})
describe('Product Visualization & Design', () => {
it('should categorize product work', () => {
expect(normalizeUseCase('Product mockup creation')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('3d product rendering')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('prototype visualization')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('industrial design')).toBe(
'Product Visualization & Design'
)
})
})
describe('Gaming & Interactive Media', () => {
it('should categorize gaming use cases', () => {
expect(normalizeUseCase('Game asset generation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game development')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('VR content creation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('interactive media')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game textures')).toBe(
'Gaming & Interactive Media'
)
})
})
describe('Architecture & Construction', () => {
it('should categorize architecture use cases', () => {
expect(normalizeUseCase('Building visualization')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('architectural rendering')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('interior design mockups')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('real estate visualization')).toBe(
'Architecture & Construction'
)
})
})
describe('Photography & Image Processing', () => {
it('should categorize photography work', () => {
expect(normalizeUseCase('Product photography')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('photo editing')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('image enhancement')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('portrait photography')).toBe(
'Photography & Image Processing'
)
})
})
describe('Research & Development', () => {
it('should categorize research work', () => {
expect(normalizeUseCase('Scientific visualization')).toBe(
'Research & Development'
)
expect(normalizeUseCase('research experiments')).toBe(
'Research & Development'
)
expect(normalizeUseCase('prototype testing')).toBe(
'Research & Development'
)
expect(normalizeUseCase('innovation projects')).toBe(
'Research & Development'
)
})
})
describe('Personal & Hobby', () => {
it('should categorize personal projects', () => {
expect(normalizeUseCase('Personal art projects')).toBe('Personal & Hobby')
expect(normalizeUseCase('hobby work')).toBe('Personal & Hobby')
expect(normalizeUseCase('creative exploration')).toBe('Personal & Hobby')
expect(normalizeUseCase('fun experiments')).toBe('Personal & Hobby')
})
})
describe('Film & Video Production', () => {
it('should categorize film work', () => {
expect(normalizeUseCase('movie production')).toBe(
'Film & Video Production'
)
expect(normalizeUseCase('video editing')).toBe('Film & Video Production')
expect(normalizeUseCase('visual effects')).toBe('Film & Video Production')
expect(normalizeUseCase('storyboard creation')).toBe(
'Film & Video Production'
)
})
})
describe('Education & Training', () => {
it('should categorize educational use cases', () => {
expect(normalizeUseCase('educational content')).toBe(
'Education & Training'
)
expect(normalizeUseCase('training materials')).toBe(
'Education & Training'
)
expect(normalizeUseCase('tutorial creation')).toBe('Education & Training')
expect(normalizeUseCase('academic projects')).toBe('Education & Training')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeUseCase('other')).toBe('Other / Undefined')
expect(normalizeUseCase('none')).toBe('Other / Undefined')
expect(normalizeUseCase('undefined')).toBe('Other / Undefined')
expect(normalizeUseCase('')).toBe('Other / Undefined')
expect(normalizeUseCase(null as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown use cases with prefix', () => {
expect(normalizeUseCase('Mysterious Use Case')).toBe(
'Uncategorized: Mysterious Use Case'
)
})
})
})
describe('normalizeSurveyResponses', () => {
it('should normalize both industry and use case', () => {
const input = {
industry: 'Film and television production',
useCase: 'Creating concept art for movies',
familiarity: 'Expert'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Film and television production',
industry_normalized: 'Film / TV / Animation',
industry_raw: 'Film and television production',
useCase: 'Creating concept art for movies',
useCase_normalized: 'Art & Illustration',
useCase_raw: 'Creating concept art for movies',
familiarity: 'Expert'
})
})
it('should handle partial responses', () => {
const input = {
industry: 'Software Development',
familiarity: 'Beginner'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Software Development',
industry_normalized: 'Software / IT / AI',
industry_raw: 'Software Development',
familiarity: 'Beginner'
})
})
it('should handle empty responses', () => {
const input = {
familiarity: 'Intermediate'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
familiarity: 'Intermediate'
})
})
it('should handle uncategorized responses', () => {
const input = {
industry: 'Unknown Creative Field',
useCase: 'Mysterious Use Case'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Unknown Creative Field',
industry_normalized: 'Uncategorized: Unknown Creative Field',
industry_raw: 'Unknown Creative Field',
useCase: 'Mysterious Use Case',
useCase_normalized: 'Uncategorized: Mysterious Use Case',
useCase_raw: 'Mysterious Use Case'
})
})
describe('Migration script example data validation', () => {
it('should correctly categorize all migration script examples', () => {
const examples = [
{
input: {
industry: 'Film and television production',
useCase: 'Creating concept art for movies'
},
expected: {
industry: 'Film / TV / Animation',
useCase: 'Art & Illustration'
}
},
{
input: {
industry: 'Marketing & Social Media',
useCase: 'YouTube thumbnail generation'
},
expected: {
industry: 'Marketing / Advertising / Social Media',
useCase: 'Content Creation & Marketing'
}
},
{
input: {
industry: 'Software Development',
useCase: 'Product mockup creation'
},
expected: {
industry: 'Software / IT / AI',
useCase: 'Product Visualization & Design'
}
},
{
input: {
industry: 'Indie Game Studio',
useCase: 'Game asset generation'
},
expected: {
industry: 'Gaming / Interactive Media',
useCase: 'Gaming & Interactive Media'
}
},
{
input: {
industry: 'Architecture firm',
useCase: 'Building visualization'
},
expected: {
industry: 'Architecture / Engineering / Construction',
useCase: 'Architecture & Construction'
}
},
{
input: {
industry: 'Custom Jewelry Design',
useCase: 'Product photography'
},
expected: {
industry: 'Fashion / Beauty / Retail',
useCase: 'Photography & Image Processing'
}
},
{
input: {
industry: 'Medical Research',
useCase: 'Scientific visualization'
},
expected: {
industry: 'Healthcare / Medical / Life Science',
useCase: 'Research & Development'
}
},
{
input: {
industry: 'Unknown Creative Field',
useCase: 'Personal art projects'
},
expected: {
industry: 'Uncategorized: Unknown Creative Field',
useCase: 'Personal & Hobby'
}
}
]
examples.forEach(({ input, expected }) => {
const result = normalizeSurveyResponses(input)
expect(result.industry_normalized).toBe(expected.industry)
expect(result.useCase_normalized).toBe(expected.useCase)
})
})
})
})

View File

@@ -1,606 +0,0 @@
/**
* Survey Response Normalization Utilities
*
* Smart categorization system to normalize free-text survey responses
* into standardized categories for better analytics breakdowns.
* Uses Fuse.js for fuzzy matching against category keywords.
*/
import Fuse from 'fuse.js'
interface CategoryMapping {
name: string
keywords: string[]
userCount?: number // For reference from analysis
}
/**
* Industry category mappings based on ~9,000 user analysis
*/
const INDUSTRY_CATEGORIES: CategoryMapping[] = [
{
name: 'Film / TV / Animation',
userCount: 2885,
keywords: [
'film',
'tv',
'television',
'animation',
'animation studio',
'tv production',
'film production',
'story',
'anime',
'video',
'cinematography',
'visual effects',
'vfx',
'vfx artist',
'movie',
'cinema',
'documentary',
'documentary filmmaker',
'broadcast',
'streaming',
'production',
'director',
'filmmaker',
'post-production',
'editing'
]
},
{
name: 'Marketing / Advertising / Social Media',
userCount: 1340,
keywords: [
'marketing',
'advertising',
'youtube',
'tiktok',
'social media',
'content creation',
'influencer',
'brand',
'promotion',
'digital marketing',
'seo',
'campaigns',
'copywriting',
'growth',
'engagement'
]
},
{
name: 'Software / IT / AI',
userCount: 1100,
keywords: [
'software',
'software development',
'software engineer',
'it',
'ai',
'ai research',
'corporate ai research',
'ai research lab',
'tech company ai research',
'developer',
'app developer',
'consulting',
'tech',
'tech startup',
'programmer',
'data science',
'machine learning',
'coding',
'programming',
'web development',
'app development',
'saas'
]
},
{
name: 'Product & Industrial Design',
userCount: 1050,
keywords: [
'product design',
'industrial',
'manufacturing',
'3d rendering',
'product visualization',
'mechanical',
'automotive',
'cad',
'prototype',
'design engineering',
'invention'
]
},
{
name: 'Fine Art / Contemporary Art',
userCount: 780,
keywords: [
'fine art',
'art',
'illustration',
'contemporary',
'artist',
'painting',
'drawing',
'sculpture',
'gallery',
'canvas',
'digital art',
'mixed media',
'abstract',
'portrait'
]
},
{
name: 'Education / Research',
userCount: 640,
keywords: [
'education',
'student',
'teacher',
'research',
'university research',
'academic ai research',
'university ai research',
'ai research at university',
'learning',
'university',
'school',
'academic',
'professor',
'curriculum',
'training',
'instruction',
'pedagogy'
]
},
{
name: 'Architecture / Engineering / Construction',
userCount: 420,
keywords: [
'architecture',
'architecture firm',
'construction',
'engineering',
'civil',
'civil engineering',
'cad',
'building',
'structural',
'landscape',
'landscape architecture',
'interior design',
'real estate',
'planning',
'blueprints'
]
},
{
name: 'Gaming / Interactive Media',
userCount: 410,
keywords: [
'gaming',
'game dev',
'game development',
'indie game studio',
'vr development',
'roblox',
'interactive',
'interactive media',
'virtual world',
'vr',
'ar',
'metaverse',
'simulation',
'unity',
'unity developer',
'unreal',
'indie games'
]
},
{
name: 'Photography / Videography',
userCount: 70,
keywords: [
'photography',
'photo',
'videography',
'camera',
'image',
'portrait',
'wedding',
'commercial photo',
'stock photography',
'photojournalism',
'event photography'
]
},
{
name: 'Fashion / Beauty / Retail',
userCount: 25,
keywords: [
'fashion',
'fashion design',
'beauty',
'beauty industry',
'jewelry',
'jewelry design',
'custom jewelry design',
'retail',
'retail store',
'style',
'clothing',
'cosmetics',
'makeup',
'accessories',
'boutique'
]
},
{
name: 'Music / Performing Arts',
userCount: 25,
keywords: [
'music',
'music production',
'vj',
'dance',
'projection mapping',
'audio visual',
'concert',
'concert production',
'performance',
'theater',
'stage',
'live events'
]
},
{
name: 'Healthcare / Medical / Life Science',
userCount: 30,
keywords: [
'healthcare',
'medical',
'medical research',
'doctor',
'biotech',
'life science',
'pharmaceutical',
'clinical',
'clinical research',
'hospital',
'medicine',
'health'
]
},
{
name: 'E-commerce / Print-on-Demand / Business',
userCount: 15,
keywords: [
'ecommerce',
'e-commerce',
'print on demand',
'shop',
'business',
'commercial',
'startup',
'entrepreneur',
'sales',
'online store'
]
},
{
name: 'Nonprofit / Government / Public Sector',
userCount: 15,
keywords: [
'501c3',
'ngo',
'government',
'public service',
'policy',
'nonprofit',
'charity',
'civic',
'community',
'social impact'
]
},
{
name: 'Adult / NSFW',
userCount: 10,
keywords: [
'nsfw',
'nsfw content',
'adult',
'adult entertainment',
'erotic',
'explicit',
'xxx',
'porn'
]
}
]
/**
* Use case category mappings based on common patterns
*/
const USE_CASE_CATEGORIES: CategoryMapping[] = [
{
name: 'Content Creation & Marketing',
keywords: [
'content creation',
'social media',
'marketing',
'marketing campaigns',
'advertising',
'youtube',
'youtube thumbnail',
'youtube thumbnail generation',
'tiktok',
'instagram',
'thumbnails',
'posts',
'campaigns',
'brand content'
]
},
{
name: 'Art & Illustration',
keywords: [
'art',
'illustration',
'drawing',
'painting',
'concept art',
'creating concept art',
'character design',
'digital art',
'fantasy art',
'portraits'
]
},
{
name: 'Product Visualization & Design',
keywords: [
'product',
'product mockup',
'product mockup creation',
'visualization',
'prototype visualization',
'design',
'prototype',
'mockup',
'3d rendering',
'industrial design',
'product photos'
]
},
{
name: 'Film & Video Production',
keywords: [
'film',
'video',
'video editing',
'movie',
'movie production',
'animation',
'vfx',
'visual effects',
'storyboard',
'storyboard creation',
'cinematography',
'post production'
]
},
{
name: 'Gaming & Interactive Media',
keywords: [
'game',
'gaming',
'game asset generation',
'game assets',
'game development',
'game textures',
'interactive',
'vr',
'vr content creation',
'ar',
'virtual',
'simulation',
'metaverse',
'textures'
]
},
{
name: 'Architecture & Construction',
keywords: [
'architecture',
'architectural rendering',
'building',
'building visualization',
'construction',
'interior design',
'interior design mockups',
'landscape',
'real estate',
'real estate visualization',
'floor plans',
'renderings'
]
},
{
name: 'Education & Training',
keywords: [
'education',
'educational',
'educational content',
'training',
'training materials',
'learning',
'teaching',
'tutorial',
'tutorial creation',
'course',
'academic',
'academic projects',
'instructional',
'workshops'
]
},
{
name: 'Research & Development',
keywords: [
'research',
'research experiments',
'development',
'experiment',
'prototype',
'prototype testing',
'testing',
'analysis',
'study',
'innovation',
'innovation projects',
'r&d',
'scientific visualization'
]
},
{
name: 'Personal & Hobby',
keywords: [
'personal',
'personal art projects',
'hobby',
'hobby work',
'fun',
'fun experiments',
'experiment',
'learning',
'curiosity',
'explore',
'creative',
'creative exploration',
'side project'
]
},
{
name: 'Photography & Image Processing',
keywords: [
'photography',
'product photography',
'portrait photography',
'photo',
'photo editing',
'image',
'image enhancement',
'portrait',
'editing',
'enhancement',
'restoration',
'photo manipulation'
]
}
]
/**
* Fuse.js configuration for category matching
*/
const FUSE_OPTIONS = {
keys: ['keywords'],
threshold: 0.53, // Higher = more lenient matching
minMatchCharLength: 5,
includeScore: true,
includeMatches: true,
ignoreLocation: true,
findAllMatches: true
}
/**
* Create Fuse instances for category matching
*/
const industryFuse = new Fuse(INDUSTRY_CATEGORIES, FUSE_OPTIONS)
const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
/**
* Normalize industry responses using Fuse.js fuzzy search
*/
export function normalizeIndustry(rawIndustry: string): string {
if (!rawIndustry || typeof rawIndustry !== 'string') {
return 'Other / Undefined'
}
const industry = rawIndustry.toLowerCase().trim()
// Handle common undefined responses
if (
industry.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = industryFuse.search(rawIndustry)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawIndustry}`
}
/**
* Normalize use case responses using Fuse.js fuzzy search
*/
export function normalizeUseCase(rawUseCase: string): string {
if (!rawUseCase || typeof rawUseCase !== 'string') {
return 'Other / Undefined'
}
const useCase = rawUseCase.toLowerCase().trim()
// Handle common undefined responses
if (
useCase.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = useCaseFuse.search(rawUseCase)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawUseCase}`
}
/**
* Apply normalization to survey responses
* Creates both normalized and raw versions of responses
*/
export function normalizeSurveyResponses(responses: {
industry?: string
useCase?: string
[key: string]: any
}) {
const normalized = { ...responses }
// Normalize industry
if (responses.industry) {
normalized.industry_normalized = normalizeIndustry(responses.industry)
normalized.industry_raw = responses.industry
}
// Normalize use case
if (responses.useCase) {
normalized.useCase_normalized = normalizeUseCase(responses.useCase)
normalized.useCase_raw = responses.useCase
}
return normalized
}

View File

@@ -1,21 +0,0 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { PromptId } from '@/schemas/apiSchema'
export async function getWorkflowFromHistory(
fetchApi: (url: string) => Promise<Response>,
promptId: PromptId
): Promise<ComfyWorkflowJSON | undefined> {
try {
const res = await fetchApi(`/history_v2/${promptId}`)
const json = await res.json()
const historyItem = json[promptId]
if (!historyItem) return undefined
const workflow = historyItem.prompt?.extra_data?.extra_pnginfo?.workflow
return workflow ?? undefined
} catch (error) {
console.error(`Failed to fetch workflow for prompt ${promptId}:`, error)
return undefined
}
}

View File

@@ -1,10 +0,0 @@
/**
* Cloud: Fetches workflow by prompt_id. Desktop: Returns undefined (workflows already in history).
*/
import { isCloud } from '@/platform/distribution/types'
import { getWorkflowFromHistory as cloudImpl } from './getWorkflowFromHistory'
export const getWorkflowFromHistory = isCloud
? cloudImpl
: async () => undefined

View File

@@ -154,13 +154,10 @@ export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph
const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
const adapter = adapterByGraph.get(graph)
if (adapter && adapter.linkConnector === connector) {
return adapter
let adapter = adapterByGraph.get(graph)
if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter)
}
const newAdapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, newAdapter)
return newAdapter
return adapter
}

View File

@@ -34,7 +34,7 @@
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none"
class="block size-full object-contain"
@load="handleImageLoad"
@error="handleImageError"
/>

View File

@@ -15,7 +15,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent outline-2',
'outline-transparent -outline-offset-2 outline-2',
borderClass,
outlineClass,
{
@@ -44,20 +44,7 @@
@wheel="handleWheel"
@contextmenu="handleContextMenu"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<div class="flex items-center">
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
@@ -145,14 +132,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -248,9 +233,6 @@ const nodeOpacity = computed(() => {
return globalOpacity
})
const hasInputs = computed(() => nonWidgetedInputs(nodeData).length > 0)
const hasOutputs = computed((): boolean => !!nodeData.outputs?.length)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()

View File

@@ -2,11 +2,8 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ $t('Node Slots Error') }}
</div>
<div v-else :class="cn('flex justify-between', unifiedWrapperClass)">
<div
v-if="filteredInputs.length"
:class="cn('flex flex-col gap-1', unifiedDotsClass)"
>
<div v-else class="lg-node-slots flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
@@ -17,10 +14,7 @@
/>
</div>
<div
v-if="nodeData?.outputs?.length"
:class="cn('ml-auto flex flex-col gap-1', unifiedDotsClass)"
>
<div v-if="nodeData?.outputs?.length" class="ml-auto flex flex-col gap-1">
<OutputSlot
v-for="(output, index) in nodeData.outputs"
:key="`output-${index}`"
@@ -39,43 +33,40 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { cn } from '@/utils/tailwindUtil'
import { isSlotObject } from '@/utils/typeGuardUtil'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
nodeData: VueNodeData
unified?: boolean
nodeData?: VueNodeData
}
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
const { nodeData = null } = defineProps<NodeSlotsProps>()
const linkedWidgetInputs = computed(() =>
unified ? linkedWidgetedInputs(nodeData) : []
)
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeData?.inputs) return []
const filteredInputs = computed(() => [
...nonWidgetedInputs(nodeData),
...linkedWidgetInputs.value
])
const unifiedWrapperClass = computed((): string =>
cn(
unified &&
'absolute inset-0 items-center pointer-events-none opacity-0 z-30'
)
)
const unifiedDotsClass = computed((): string =>
cn(
unified &&
'grid grid-cols-1 grid-rows-1 gap-0 [&>*]:row-span-full [&>*]:col-span-full place-items-center'
)
)
return nodeData.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
// This slot has a widget, so we should not display it separately
return false
}
return true
})
.map((input) =>
isSlotObject(input)
? input
: ({
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)

View File

@@ -12,9 +12,9 @@
: 'pointer-events-none'
)
"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
@pointerdown.stop="handleWidgetPointerEvent"
@pointermove.stop="handleWidgetPointerEvent"
@pointerup.stop="handleWidgetPointerEvent"
>
<div
v-for="(widget, index) in processedWidgets"
@@ -24,12 +24,7 @@
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-center',
widget.slotMetadata?.linked && 'opacity-100'
)
"
class="z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
>
<InputSlot
v-if="widget.slotMetadata"
@@ -40,7 +35,7 @@
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index"
dot-only
:dot-only="true"
/>
</div>
<!-- Widget Component -->
@@ -88,10 +83,10 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
event.stopPropagation()
forwardEventToCanvas(event)
const handleWidgetPointerEvent = (event: PointerEvent) => {
if (!shouldHandleNodePointerEvents.value) {
forwardEventToCanvas(event)
}
}
// Error boundary implementation
@@ -145,7 +140,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) {
widgetOptions = { ...widget.options, disabled: true }
widgetOptions = widget.options
? { ...widget.options, disabled: true }
: { disabled: true }
}
const simplified: SimplifiedWidget = {

View File

@@ -1,5 +1,6 @@
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import type { Fn } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
@@ -554,8 +555,6 @@ export function useSlotLinkInteraction({
if (event.button !== 0) return
if (!nodeId) return
if (pointerSession.isActive()) return
event.preventDefault()
event.stopPropagation()
const canvas = app.canvas
const graph = canvas?.graph
@@ -614,7 +613,7 @@ export function useSlotLinkInteraction({
if (shouldBatchDisconnectOutputLinks && resolvedNode) {
resolvedNode.disconnectOutput(index)
canvas.setDirty(true, true)
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
return
@@ -635,18 +634,20 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (activeAdapter) {
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
}
syncRenderLinkOrigins()
@@ -677,19 +678,21 @@ export function useSlotLinkInteraction({
toCanvasPointerEvent(event)
updatePointerState(event)
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
pointerSession.register(
useEventListener('pointermove', handlePointerMove, {
useEventListener(window, 'pointermove', handlePointerMove, {
capture: true
}),
useEventListener('pointerup', handlePointerUp, {
useEventListener(window, 'pointerup', handlePointerUp, {
capture: true
}),
useEventListener('pointercancel', handlePointerCancel, {
useEventListener(window, 'pointercancel', handlePointerCancel, {
capture: true
})
)
@@ -707,10 +710,12 @@ export function useSlotLinkInteraction({
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
canvas.setDirty(true, true)
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
}
tryOnScopeDispose(() => {
onBeforeUnmount(() => {
if (pointerSession.isActive()) {
cleanupInteraction()
}

View File

@@ -1,127 +0,0 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
INodeInputSlot,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type { LinkId } from '@/renderer/core/layout/types'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { describe, it } from 'vitest'
function makeFakeInputSlot(
name: string,
withWidget = false,
link: LinkId | null = null
): INodeInputSlot {
const widget: IWidgetLocator | undefined = withWidget ? { name } : undefined
return {
name,
widget,
link,
boundingRect: [0, 0, 0, 0],
type: 'FAKE'
}
}
function makeFakeNodeData(inputs: INodeInputSlot[]): VueNodeData {
const nodeData: Partial<VueNodeData> = { inputs }
return nodeData as VueNodeData
}
describe('nodeDataUtils', () => {
describe('nonWidgetedInputs', () => {
it('should handle an empty inputs list', () => {
const inputs: INodeInputSlot[] = []
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only widgeted inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first', true),
makeFakeInputSlot('second', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only slot inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second')
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
it('should handle a list of mixed inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
describe('linkedWidgetedInputs', () => {
it('should return input slots that are bound to widgets and are linked: none present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should return input slots that are bound to widgets and are linked: one present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(1)
})
it('should return input slots that are bound to widgets and are linked: multiple present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1),
makeFakeInputSlot('fifth', true, 2)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
})

View File

@@ -1,36 +0,0 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { INodeInputSlot, INodeSlot } from '@/lib/litegraph/src/interfaces'
import { isSlotObject } from '@/utils/typeGuardUtil'
function coerceINodeSlot(input: INodeInputSlot): INodeSlot {
return isSlotObject(input)
? input
: {
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0]
}
}
function inputHasWidget(input: INodeInputSlot) {
return isSlotObject(input) && 'widget' in input && input.widget
}
export function nonWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => !inputHasWidget(input))
.map(coerceINodeSlot)
}
export function linkedWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => inputHasWidget(input) && !!input.link)
.map(coerceINodeSlot)
}

View File

@@ -2,8 +2,8 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'not-disabled:bg-node-component-widget-input-surface',
'not-disabled:text-node-component-widget-input',
'bg-node-component-widget-input-surface',
'text-node-component-widget-input',
// Outline
'border-none',
'outline outline-offset-[-1px] outline-node-stroke',

View File

@@ -13,7 +13,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
export const zQueueIndex = z.number()
export const zPromptId = z.string()
export type PromptId = z.infer<typeof zPromptId>
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>

View File

@@ -18,7 +18,6 @@ import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -1272,14 +1271,6 @@ export class ComfyApp {
'afterConfigureGraph',
missingNodeTypes
)
// Track workflow import with missing node information
useTelemetry()?.trackWorkflowImported({
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
)
})
await useWorkflowService().afterLoadNewGraph(
workflow,
this.graph.serialize() as unknown as ComfyWorkflowJSON

View File

@@ -69,7 +69,6 @@ export const useDialogService = () => {
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined

View File

@@ -61,7 +61,7 @@ function getSubgraphsFromInstanceIds(
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] | undefined {
): Subgraph[] {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
@@ -69,10 +69,7 @@ function getSubgraphsFromInstanceIds(
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) {
console.warn(`Subgraph not found: ${currentPart}`)
return undefined
}
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
@@ -83,9 +80,7 @@ function getSubgraphsFromInstanceIds(
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
function executionIdToNodeLocatorId(
nodeId: string | number
): NodeLocatorId | undefined {
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
@@ -97,7 +92,6 @@ function executionIdToNodeLocatorId(
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
if (!subgraphs) return undefined
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}

View File

@@ -2,9 +2,7 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, toRaw, toValue } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud'
import type {
ComfyWorkflowJSON,
NodeId
@@ -381,37 +379,24 @@ export class TaskItemImpl {
}
public async loadWorkflow(app: ComfyApp) {
let workflowData = this.workflow
if (isCloud && !workflowData && this.isHistory) {
workflowData = await getWorkflowFromHistory(
(url) => app.api.fetchApi(url),
this.promptId
)
}
if (!workflowData) {
if (!this.workflow) {
return
}
await app.loadGraphData(toRaw(workflowData))
if (!this.outputs) {
return
}
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
public flatten(): TaskItemImpl[] {
@@ -507,7 +492,7 @@ export const useQueueStore = defineStore('queue', () => {
const currentHistory = toValue(historyTasks)
const items = reconcileHistory(
const { items } = reconcileHistory(
history.History,
currentHistory.map((impl) => impl.toTaskItem()),
toValue(maxHistoryItems),

View File

@@ -140,16 +140,16 @@ export interface ComfyExtension {
/**
* Allows the extension to add context menu items to canvas right-click menus
* @param canvas The canvas instance
* @returns An array of context menu items to add (null values represent separators)
* @returns An array of context menu items to add
*/
getCanvasMenuItems?(canvas: LGraphCanvas): (IContextMenuValue | null)[]
getCanvasMenuItems?(canvas: LGraphCanvas): IContextMenuValue[]
/**
* Allows the extension to add context menu items to node right-click menus
* @param node The node being right-clicked
* @returns An array of context menu items to add (null values represent separators)
* @returns An array of context menu items to add
*/
getNodeMenuItems?(node: LGraphNode): (IContextMenuValue | null)[]
getNodeMenuItems?(node: LGraphNode): IContextMenuValue[]
/**
* Allows the extension to add additional handling to the node before it is registered with **LGraph**

View File

@@ -463,27 +463,6 @@ export function traverseNodesDepthFirst<T = void>(
}
}
/**
* Reduces all nodes in a graph hierarchy to a single value using a reducer function.
* Single-pass traversal for efficient aggregation.
*
* @param graph - The root graph to traverse
* @param reducer - Function that reduces each node into the accumulator
* @param initialValue - The initial accumulator value
* @returns The final reduced value
*/
export function reduceAllNodes<T>(
graph: LGraph | Subgraph,
reducer: (accumulator: T, node: LGraphNode) => T,
initialValue: T
): T {
let result = initialValue
forEachNode(graph, (node) => {
result = reducer(result, node)
})
return result
}
/**
* Options for collectFromNodes function
*/

View File

@@ -15,7 +15,6 @@
<GlobalToast />
<RerouteMigrationToast />
<VueNodesMigrationToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -41,16 +40,13 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import VueNodesMigrationToast from '@/components/toast/VueNodesMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
@@ -61,7 +57,6 @@ import { useKeybindingService } from '@/services/keybindingService'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useModelStore } from '@/stores/modelStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -90,13 +85,6 @@ const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const telemetry = useTelemetry()
const firebaseAuthStore = useFirebaseAuthStore()
let hasTrackedLogin = false
let visibilityListener: (() => void) | null = null
let tabCountInterval: number | null = null
let tabCountChannel: BroadcastChannel | null = null
watch(
() => colorPaletteStore.completedActivePalette,
(newTheme) => {
@@ -260,22 +248,6 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
// Clean up page visibility listener
if (visibilityListener) {
document.removeEventListener('visibilitychange', visibilityListener)
visibilityListener = null
}
// Clean up tab count tracking
if (tabCountInterval) {
window.clearInterval(tabCountInterval)
tabCountInterval = null
}
if (tabCountChannel) {
tabCountChannel.close()
tabCountChannel = null
}
})
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
@@ -294,61 +266,6 @@ void nextTick(() => {
const onGraphReady = () => {
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
}
// Set up page visibility tracking (cloud only)
if (isCloud && telemetry && !visibilityListener) {
visibilityListener = () => {
telemetry.trackPageVisibilityChanged({
visibility_state: document.visibilityState as 'visible' | 'hidden'
})
}
document.addEventListener('visibilitychange', visibilityListener)
}
// Set up tab count tracking (cloud only)
if (isCloud && telemetry && !tabCountInterval) {
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
const activeTabs = new Map<string, number>()
const currentTabId = crypto.randomUUID()
// Listen for heartbeats from other tabs
tabCountChannel.onmessage = (event) => {
if (
event.data.type === 'heartbeat' &&
event.data.tabId !== currentTabId
) {
activeTabs.set(event.data.tabId, Date.now())
}
}
// 30-second heartbeat interval
tabCountInterval = window.setInterval(() => {
const now = Date.now()
// Clean up stale tabs (no heartbeat for 45 seconds)
activeTabs.forEach((lastHeartbeat, tabId) => {
if (now - lastHeartbeat > 45000) {
activeTabs.delete(tabId)
}
})
// Broadcast our heartbeat
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
// Track tab count (include current tab)
const tabCount = activeTabs.size + 1
telemetry.trackTabCount({ tab_count: tabCount })
}, 30000)
// Send initial heartbeat
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
const makeSuccessResponse = () =>
new Response('{}', {
status: 200,
statusText: 'OK',
headers: { 'Content-Type': 'application/json' }
})
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}
const createDeferred = <T>(): Deferred<T> => {
let resolve: (value: T) => void
let reject: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
// @ts-expect-error initialized via closure assignments above
return { promise, resolve, reject }
}
const mockModules = async () => {
vi.resetModules()
const getAuthHeader = vi.fn(async () => ({ Authorization: 'Bearer token' }))
vi.doMock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `/api${path}`)
}
}))
vi.doMock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.doMock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader
}))
}))
const module = await import('@/platform/auth/session/useSessionCookie')
return { getAuthHeader, useSessionCookie: module.useSessionCookie }
}
describe('useSessionCookie', () => {
it('deduplicates in-flight session creation', async () => {
const { useSessionCookie, getAuthHeader } = await mockModules()
const postDeferred = createDeferred<Response>()
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(() => postDeferred.promise)
const { createSession } = useSessionCookie()
const firstCall = createSession()
const secondCall = createSession()
await Promise.resolve()
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(getAuthHeader).toHaveBeenCalledTimes(1)
postDeferred.resolve(makeSuccessResponse())
await expect(firstCall).resolves.toBeUndefined()
await expect(secondCall).resolves.toBeUndefined()
fetchSpy.mockRestore()
})
it('aborts pending create on logout and skips new ones while logout is in progress', async () => {
const { useSessionCookie, getAuthHeader } = await mockModules()
const firstPostDeferred = createDeferred<Response>()
const deleteDeferred = createDeferred<Response>()
let capturedSignal: AbortSignal | undefined
const fetchSpy = vi.spyOn(globalThis, 'fetch')
fetchSpy
.mockImplementationOnce((_, init?: RequestInit) => {
capturedSignal = init?.signal as AbortSignal | undefined
return firstPostDeferred.promise
})
.mockImplementationOnce((_, init?: RequestInit) => {
expect(init?.method).toBe('DELETE')
return deleteDeferred.promise
})
.mockImplementation((_, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve(makeSuccessResponse())
}
return Promise.resolve(makeSuccessResponse())
})
const { createSession, deleteSession } = useSessionCookie()
const createPromise = createSession()
await Promise.resolve()
const logoutPromise = deleteSession()
await Promise.resolve()
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(capturedSignal?.aborted).toBe(true)
const abortError = new Error('aborted')
abortError.name = 'AbortError'
firstPostDeferred.reject(abortError)
await expect(createPromise).resolves.toBeUndefined()
await Promise.resolve()
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(getAuthHeader).toHaveBeenCalledTimes(1)
await expect(createSession()).resolves.toBeUndefined()
expect(fetchSpy).toHaveBeenCalledTimes(2)
deleteDeferred.resolve(makeSuccessResponse())
await expect(logoutPromise).resolves.toBeUndefined()
await expect(createSession()).resolves.toBeUndefined()
expect(fetchSpy).toHaveBeenCalledTimes(3)
fetchSpy.mockRestore()
})
})

View File

@@ -1,64 +0,0 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/LGraphBadge'
import type { LGraphIcon } from '@/lib/litegraph/src/LGraphIcon'
import { subgraphTest } from '../../litegraph/subgraph/fixtures/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
const mockIcon: Partial<LGraphIcon> = { unicode: '\ue96b' }
const badge: Partial<LGraphBadge> = {
icon: mockIcon as LGraphIcon,
text: '$0.05/Run'
}
mockNode.badges = [badge as LGraphBadge]
function getBadgeText(node: LGraphNode): string {
const badge = node.badges[0]
return (typeof badge === 'function' ? badge() : badge).text
}
describe('subgraph pricing', () => {
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {
const { subgraphNode } = subgraphWithNode
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(0)
}
)
subgraphTest(
'should return the price of a single contained API node',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
}
)
subgraphTest(
'should return the number of api nodes if more than one exists',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
for (let i = 0; i < 5; i++) subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
})

View File

@@ -1,213 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubscriptionPanel from '@/platform/cloud/subscription/components/SubscriptionPanel.vue'
// Mock composables
const mockSubscriptionData = {
isActiveSubscription: false,
isCancelled: false,
formattedRenewalDate: '2024-12-31',
formattedEndDate: '2024-12-31',
formattedMonthlyPrice: '$9.99',
manageSubscription: vi.fn(),
handleInvoiceHistory: vi.fn()
}
const mockCreditsData = {
totalCredits: '10.00',
monthlyBonusCredits: '5.00',
prepaidCredits: '5.00',
isLoadingBalance: false
}
const mockActionsData = {
isLoadingSupport: false,
refreshTooltip: 'Refreshes on 2024-12-31',
handleAddApiCredits: vi.fn(),
handleMessageSupport: vi.fn(),
handleRefresh: vi.fn(),
handleLearnMoreClick: vi.fn()
}
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => mockSubscriptionData
}))
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionCredits',
() => ({
useSubscriptionCredits: () => mockCreditsData
})
)
vi.mock(
'@/platform/cloud/subscription/composables/useSubscriptionActions',
() => ({
useSubscriptionActions: () => mockActionsData
})
)
// Create i18n instance for testing
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subscription: {
title: 'Subscription',
perMonth: '/ month',
subscribeNow: 'Subscribe Now',
manageSubscription: 'Manage Subscription',
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
monthlyCreditsRollover: 'Monthly credits rollover info',
prepaidCreditsInfo: 'Prepaid credits info',
viewUsageHistory: 'View Usage History',
addCredits: 'Add Credits',
yourPlanIncludes: 'Your plan includes',
learnMore: 'Learn More',
messageSupport: 'Message Support',
invoiceHistory: 'Invoice History',
renewsDate: 'Renews {date}',
expiresDate: 'Expires {date}'
}
}
}
})
function createWrapper(overrides = {}) {
return mount(SubscriptionPanel, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
CloudBadge: true,
SubscribeButton: true,
SubscriptionBenefits: true,
Button: {
template:
'<button @click="$emit(\'click\')" :disabled="loading" :data-testid="label" :data-icon="icon">{{ label }}</button>',
props: [
'loading',
'label',
'icon',
'text',
'severity',
'size',
'iconPos',
'pt'
],
emits: ['click']
},
Skeleton: {
template: '<div class="skeleton"></div>'
}
}
},
...overrides
})
}
describe('SubscriptionPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('subscription state functionality', () => {
it('shows correct UI for active subscription', () => {
mockSubscriptionData.isActiveSubscription = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Manage Subscription')
expect(wrapper.text()).toContain('Add Credits')
})
it('shows correct UI for inactive subscription', () => {
mockSubscriptionData.isActiveSubscription = false
const wrapper = createWrapper()
expect(wrapper.findComponent({ name: 'SubscribeButton' }).exists()).toBe(
true
)
expect(wrapper.text()).not.toContain('Manage Subscription')
expect(wrapper.text()).not.toContain('Add Credits')
})
it('shows renewal date for active non-cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = false
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Renews 2024-12-31')
})
it('shows expiry date for cancelled subscription', () => {
mockSubscriptionData.isActiveSubscription = true
mockSubscriptionData.isCancelled = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Expires 2024-12-31')
})
})
describe('credit display functionality', () => {
it('displays dynamic credit values correctly', () => {
const wrapper = createWrapper()
expect(wrapper.text()).toContain('$10.00') // totalCredits
expect(wrapper.text()).toContain('$5.00') // both monthlyBonus and prepaid
})
it('shows loading skeleton when fetching balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBeGreaterThan(0)
})
it('hides skeleton when balance loaded', () => {
mockCreditsData.isLoadingBalance = false
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBe(0)
})
})
describe('action buttons', () => {
it('should call handleLearnMoreClick when learn more is clicked', async () => {
const wrapper = createWrapper()
const learnMoreButton = wrapper.find('[data-testid="Learn More"]')
await learnMoreButton.trigger('click')
expect(mockActionsData.handleLearnMoreClick).toHaveBeenCalledOnce()
})
it('should call handleMessageSupport when message support is clicked', async () => {
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
await supportButton.trigger('click')
expect(mockActionsData.handleMessageSupport).toHaveBeenCalledOnce()
})
it('should call handleRefresh when refresh button is clicked', async () => {
const wrapper = createWrapper()
// Find the refresh button by icon
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
await refreshButton.trigger('click')
expect(mockActionsData.handleRefresh).toHaveBeenCalledOnce()
})
})
describe('loading states', () => {
it('should show loading state on support button when loading', () => {
mockActionsData.isLoadingSupport = true
const wrapper = createWrapper()
const supportButton = wrapper.find('[data-testid="Message Support"]')
expect(supportButton.attributes('disabled')).toBeDefined()
})
it('should show loading state on refresh button when loading balance', () => {
mockCreditsData.isLoadingBalance = true
const wrapper = createWrapper()
const refreshButton = wrapper.find('[data-icon="pi pi-sync"]')
expect(refreshButton.attributes('disabled')).toBeDefined()
})
})
})

View File

@@ -1,137 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
// Mock dependencies
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockT = vi.fn((key: string) => {
if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
return key
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))
const mockFormattedRenewalDate = { value: '2024-12-31' }
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
fetchStatus: mockFetchStatus,
formattedRenewalDate: mockFormattedRenewalDate
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
})
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
writable: true,
value: mockOpen
})
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormattedRenewalDate.value = '2024-12-31'
})
describe('refreshTooltip', () => {
it('should format tooltip with renewal date', () => {
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes on 2024-12-31')
})
it('should use fallback text when no renewal date', () => {
mockFormattedRenewalDate.value = ''
const { refreshTooltip } = useSubscriptionActions()
expect(refreshTooltip.value).toBe('Refreshes on next billing cycle')
expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
})
})
describe('handleAddApiCredits', () => {
it('should call showTopUpCreditsDialog', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
})
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(isLoadingSupport.value).toBe(false)
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})
describe('handleRefresh', () => {
it('should call both fetchBalance and fetchStatus', async () => {
const { handleRefresh } = useSubscriptionActions()
await handleRefresh()
expect(mockFetchBalance).toHaveBeenCalledOnce()
expect(mockFetchStatus).toHaveBeenCalledOnce()
})
it('should handle errors gracefully', async () => {
mockFetchBalance.mockRejectedValueOnce(new Error('Fetch failed'))
const { handleRefresh } = useSubscriptionActions()
// Should not throw
await expect(handleRefresh()).resolves.toBeUndefined()
})
})
describe('handleLearnMoreClick', () => {
it('should open learn more URL', () => {
const { handleLearnMoreClick } = useSubscriptionActions()
handleLearnMoreClick()
expect(mockOpen).toHaveBeenCalledWith(
'https://docs.comfy.org/get_started/cloud',
'_blank'
)
})
})
})

View File

@@ -1,146 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn(() => ({
onAuthStateChanged: vi.fn(),
setPersistence: vi.fn()
}))
}))
vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn(() => {
// Mock the callback to be called immediately for testing
return vi.fn()
}),
onIdTokenChanged: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined),
browserLocalPersistence: {},
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
}
}))
// Mock other dependencies
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
track: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
headers: {}
})
}))
// Mock formatMetronomeCurrency
vi.mock('@/utils/formatUtil', () => ({
formatMetronomeCurrency: vi.fn((micros: number) => {
// Simple mock that converts micros to dollars
return (micros / 1000000).toFixed(2)
})
}))
describe('useSubscriptionCredits', () => {
let authStore: ReturnType<typeof useFirebaseAuthStore>
beforeEach(() => {
setActivePinia(createPinia())
authStore = useFirebaseAuthStore()
vi.clearAllMocks()
})
describe('totalCredits', () => {
it('should return "0.00" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should return "0.00" when amount_micros is missing', () => {
authStore.balance = {} as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
it('should format amount_micros correctly', () => {
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('5.00')
})
it('should handle formatting errors gracefully', async () => {
const mockFormatMetronomeCurrency = vi.mocked(
await import('@/utils/formatUtil')
).formatMetronomeCurrency
mockFormatMetronomeCurrency.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
authStore.balance = { amount_micros: 5000000 } as any
const { totalCredits } = useSubscriptionCredits()
expect(totalCredits.value).toBe('0.00')
})
})
describe('monthlyBonusCredits', () => {
it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
authStore.balance = {} as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('0.00')
})
it('should format cloud_credit_balance_micros correctly', () => {
authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
const { monthlyBonusCredits } = useSubscriptionCredits()
expect(monthlyBonusCredits.value).toBe('2.50')
})
})
describe('prepaidCredits', () => {
it('should return "0.00" when prepaid_balance_micros is missing', () => {
authStore.balance = {} as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('0.00')
})
it('should format prepaid_balance_micros correctly', () => {
authStore.balance = { prepaid_balance_micros: 7500000 } as any
const { prepaidCredits } = useSubscriptionCredits()
expect(prepaidCredits.value).toBe('7.50')
})
})
describe('isLoadingBalance', () => {
it('should reflect authStore.isFetchingBalance', () => {
authStore.isFetchingBalance = true
const { isLoadingBalance } = useSubscriptionCredits()
expect(isLoadingBalance.value).toBe(true)
authStore.isFetchingBalance = false
expect(isLoadingBalance.value).toBe(false)
})
})
})

View File

@@ -21,8 +21,8 @@ function createHistoryItem(promptId: string, queueIndex = 0): TaskItem {
}
}
function getAllPromptIds(result: TaskItem[]): string[] {
return result.map((item) => item.prompt[1])
function getAllPromptIds(result: { items: TaskItem[] }): string[] {
return result.items.map((item) => item.prompt[1])
}
describe('reconcileHistory (V1)', () => {
@@ -74,9 +74,9 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory(serverHistory, [], 10, undefined)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('item-1')
expect(result.items[1].prompt[1]).toBe('item-2')
})
})
@@ -144,9 +144,9 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory(serverHistory, clientHistory, 2, 10)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('new-1')
expect(result.items[1].prompt[1]).toBe('new-2')
})
})
@@ -168,13 +168,13 @@ describe('reconcileHistory (V1)', () => {
const result = reconcileHistory([], clientHistory, 10, 5)
expect(result).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10, undefined)
expect(result).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
})
})
@@ -295,9 +295,9 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory(serverHistory, clientHistory, 2)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('new-1')
expect(result[1].prompt[1]).toBe('new-2')
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('new-1')
expect(result.items[1].prompt[1]).toBe('new-2')
})
})
@@ -310,9 +310,9 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory(serverHistory, [], 10)
expect(result).toHaveLength(2)
expect(result[0].prompt[1]).toBe('item-1')
expect(result[1].prompt[1]).toBe('item-2')
expect(result.items).toHaveLength(2)
expect(result.items[0].prompt[1]).toBe('item-1')
expect(result.items[1].prompt[1]).toBe('item-2')
})
it('should return empty result when server history is empty', () => {
@@ -323,13 +323,13 @@ describe('reconcileHistory (V2/Cloud)', () => {
const result = reconcileHistory([], clientHistory, 10)
expect(result).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
it('should return empty result when both collections are empty', () => {
const result = reconcileHistory([], [], 10)
expect(result).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
})
})

View File

@@ -1,111 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getWorkflowFromHistory } from '@/platform/workflow/cloud/getWorkflowFromHistory'
const mockWorkflow: ComfyWorkflowJSON = {
id: 'test-workflow-id',
revision: 0,
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
const mockHistoryResponse = {
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client',
extra_pnginfo: {
workflow: mockWorkflow
}
}
},
outputs: {},
status: {
status_str: 'success',
completed: true,
messages: []
}
}
}
describe('getWorkflowFromHistory', () => {
it('should fetch workflow from /history_v2/{prompt_id} endpoint', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(mockFetchApi).toHaveBeenCalledWith('/history_v2/test-prompt-id')
})
it('should extract and return workflow from response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => mockHistoryResponse
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toEqual(mockWorkflow)
})
it('should return undefined when prompt_id not found in response', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'nonexistent-id')
expect(result).toBeUndefined()
})
it('should return undefined when workflow is missing from extra_pnginfo', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => ({
'test-prompt-id': {
prompt: {
priority: 1,
prompt_id: 'test-prompt-id',
extra_data: {
client_id: 'test-client'
}
},
outputs: {}
}
})
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle fetch errors gracefully', async () => {
const mockFetchApi = vi.fn().mockRejectedValue(new Error('Network error'))
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
it('should handle malformed JSON responses', async () => {
const mockFetchApi = vi.fn().mockResolvedValue({
json: async () => {
throw new Error('Invalid JSON')
}
})
const result = await getWorkflowFromHistory(mockFetchApi, 'test-prompt-id')
expect(result).toBeUndefined()
})
})

View File

@@ -156,11 +156,14 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
expect(result).toBe('123')
})
it('should return undefined when conversion fails', () => {
it('should return null when conversion fails', () => {
// Mock app.graph.getNodeById to return null (node not found)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
// This should throw an error as the node is not found
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
'Subgraph not found: 999'
)
})
})

View File

@@ -13,11 +13,6 @@ import {
createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {

View File

@@ -1,175 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { TaskItemImpl } from '@/stores/queueStore'
import * as getWorkflowModule from '@/platform/workflow/cloud'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => ({
invokeExtensions: vi.fn()
}))
}))
const mockWorkflow: ComfyWorkflowJSON = {
id: 'test-workflow-id',
revision: 0,
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
const createHistoryTaskWithWorkflow = (): TaskItemImpl => {
return new TaskItemImpl(
'History',
[
0, // queueIndex
'test-prompt-id', // promptId
{}, // promptInputs
{
client_id: 'test-client',
extra_pnginfo: {
workflow: mockWorkflow
}
},
[] // outputsToExecute
],
{
status_str: 'success',
completed: true,
messages: []
},
{} // outputs
)
}
const createHistoryTaskWithoutWorkflow = (): TaskItemImpl => {
return new TaskItemImpl(
'History',
[
0,
'test-prompt-id',
{},
{
client_id: 'test-client'
// No extra_pnginfo.workflow
},
[]
],
{
status_str: 'success',
completed: true,
messages: []
},
{}
)
}
describe('TaskItemImpl.loadWorkflow - cloud history workflow fetching', () => {
let mockApp: ComfyApp
let mockFetchApi: ReturnType<typeof vi.fn>
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockFetchApi = vi.fn()
mockApp = {
loadGraphData: vi.fn(),
nodeOutputs: {},
api: {
fetchApi: mockFetchApi
}
} as unknown as ComfyApp
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory')
})
it('should load workflow directly when workflow is in extra_pnginfo', async () => {
const task = createHistoryTaskWithWorkflow()
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(mockFetchApi).not.toHaveBeenCalled()
})
it('should fetch workflow from cloud when workflow is missing from history task', async () => {
const task = createHistoryTaskWithoutWorkflow()
// Mock getWorkflowFromHistory to return workflow
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
mockWorkflow
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalledWith(
expect.any(Function),
'test-prompt-id'
)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
})
it('should not load workflow when fetch returns undefined', async () => {
const task = createHistoryTaskWithoutWorkflow()
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
undefined
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should only fetch for history tasks, not running tasks', async () => {
const runningTask = new TaskItemImpl(
'Running',
[
0,
'test-prompt-id',
{},
{
client_id: 'test-client'
},
[]
],
undefined,
{}
)
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
mockWorkflow
)
await runningTask.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).not.toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should handle fetch errors gracefully by returning undefined', async () => {
const task = createHistoryTaskWithoutWorkflow()
vi.spyOn(getWorkflowModule, 'getWorkflowFromHistory').mockResolvedValue(
undefined
)
await task.loadWorkflow(mockApp)
expect(getWorkflowModule.getWorkflowFromHistory).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
})

View File

@@ -1,17 +1,13 @@
import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
import type { IncomingMessage, ServerResponse } from 'http'
import { Readable } from 'stream'
import type { ReadableStream as NodeReadableStream } from 'stream/web'
import { visualizer } from 'rollup-plugin-visualizer'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { defineConfig } from 'vite'
import type { ProxyOptions, UserConfig } from 'vite'
import type { UserConfig } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -53,74 +49,10 @@ const DEV_SEVER_FALLBACK_URL =
const DEV_SERVER_COMFYUI_URL =
DEV_SERVER_COMFYUI_ENV_URL || DEV_SEVER_FALLBACK_URL
// Cloud proxy configuration
const cloudProxyConfig =
DISTRIBUTION === 'cloud' ? { secure: false, changeOrigin: true } : {}
function handleGcsRedirect(
proxyRes: IncomingMessage,
_req: IncomingMessage,
res: ServerResponse
) {
const location = proxyRes.headers.location
const isGcsRedirect =
proxyRes.statusCode === 302 &&
location?.includes('storage.googleapis.com') &&
proxyRes.headers.via?.includes('google')
// Not a GCS redirect - pass through normally
if (!isGcsRedirect || !location) {
Object.keys(proxyRes.headers).forEach((key) => {
const value = proxyRes.headers[key]
if (value !== undefined) {
res.setHeader(key, value)
}
})
res.writeHead(proxyRes.statusCode || 200)
proxyRes.pipe(res)
return
}
// GCS redirect detected - fetch server-side to avoid CORS
fetch(location)
.then(async (gcsResponse) => {
if (!gcsResponse.body) {
res.statusCode = 500
res.end('Empty response from GCS')
return
}
// Set response headers from GCS
res.statusCode = 200
res.setHeader(
'Content-Type',
gcsResponse.headers.get('content-type') || 'application/octet-stream'
)
const contentLength = gcsResponse.headers.get('content-length')
if (contentLength) {
res.setHeader('Content-Length', contentLength)
}
// Convert Web ReadableStream to Node.js stream and pipe to client
const readable = Readable.fromWeb(gcsResponse.body as NodeReadableStream)
readable.pipe(res)
})
.catch((error) => {
console.error('Error fetching from GCS:', error)
res.statusCode = 500
res.end('Error fetching media')
})
}
const gcsRedirectProxyConfig: ProxyOptions = {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,
selfHandleResponse: true,
configure: (proxy) => {
proxy.on('proxyRes', handleGcsRedirect)
}
}
export default defineConfig({
base: '',
server: {
@@ -148,13 +80,6 @@ export default defineConfig({
...cloudProxyConfig
},
...(DISTRIBUTION === 'cloud'
? {
'/api/view': gcsRedirectProxyConfig,
'/api/viewvideo': gcsRedirectProxyConfig
}
: {}),
'/api': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig,
@@ -290,27 +215,6 @@ export default defineConfig({
template: 'treemap' // or 'sunburst', 'network'
})
]
: []),
// Sentry sourcemap upload plugin
// Only runs during cloud production builds when all Sentry env vars are present
// Requires: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT env vars
...(DISTRIBUTION === 'cloud' &&
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
!IS_DEV
? [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
// Delete source maps after upload to prevent public access
filesToDeleteAfterUpload: ['**/*.map']
}
})
]
: [])
],