Compare commits
2 Commits
v1.32.0
...
cloud/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5fcc5357 | ||
|
|
52876da4a9 |
13
.env_example
@@ -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
|
||||
|
||||
1
.github/workflows/weekly-docs-check.yaml
vendored
@@ -142,3 +142,4 @@ jobs:
|
||||
documentation
|
||||
automated
|
||||
draft: true
|
||||
assignees: ${{ github.repository_owner }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 97 KiB |
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@click.stop
|
||||
@pointerdown.stop.capture
|
||||
@pointermove.stop.capture
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
}>()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,6 @@ export const useDialogService = () => {
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
| 'subscription'
|
||||
) {
|
||||
const props = panel ? { props: { defaultPanel: panel } } : undefined
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)()
|
||||
|
||||
139
tests-ui/platform/auth/session/useSessionCookie.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
100
vite.config.mts
@@ -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']
|
||||
}
|
||||
})
|
||||
]
|
||||
: [])
|
||||
],
|
||||
|
||||
|
||||