mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 00:39:49 +00:00
Compare commits
11 Commits
api-change
...
backport-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195b766f86 | ||
|
|
e1706a8f13 | ||
|
|
1322a56653 | ||
|
|
6491db6f68 | ||
|
|
041ce2decb | ||
|
|
ce298c43f4 | ||
|
|
40a7cbb83e | ||
|
|
9ddead24d6 | ||
|
|
6186e81b98 | ||
|
|
04eb224822 | ||
|
|
dd90288846 |
13
.env_example
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,3 +37,8 @@ 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
1
.github/workflows/weekly-docs-check.yaml
vendored
@@ -142,4 +142,3 @@ jobs:
|
||||
documentation
|
||||
automated
|
||||
draft: true
|
||||
assignees: ${{ github.repository_owner }}
|
||||
|
||||
@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
|
||||
execute: async () => await electron.setBasePath(),
|
||||
name: 'Base path',
|
||||
shortDescription: 'Change the application base path.',
|
||||
errorDescription: 'Unable to open the base path. Please select a new one.',
|
||||
errorDescription:
|
||||
'The current base path is invalid or unsafe. Please select a new location.',
|
||||
description:
|
||||
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
|
||||
isInstallationFix: true,
|
||||
|
||||
@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
const electron = electronAPI()
|
||||
|
||||
// Reactive state
|
||||
const lastUpdate = ref<InstallValidation | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
const isRunningTerminalCommand = computed(() =>
|
||||
tasks.value
|
||||
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
.some((task) => getRunner(task)?.executing)
|
||||
)
|
||||
|
||||
const unsafeBasePath = computed(
|
||||
() => lastUpdate.value?.unsafeBasePath === true
|
||||
)
|
||||
const unsafeBasePathReason = computed(
|
||||
() => lastUpdate.value?.unsafeBasePathReason
|
||||
)
|
||||
|
||||
// Task list
|
||||
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
|
||||
|
||||
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
* @param validationUpdate Update details passed in by electron
|
||||
*/
|
||||
const processUpdate = (validationUpdate: InstallValidation) => {
|
||||
lastUpdate.value = validationUpdate
|
||||
const update = validationUpdate as IndexedUpdate
|
||||
isRefreshing.value = true
|
||||
|
||||
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
}
|
||||
|
||||
const execute = async (task: MaintenanceTask) => {
|
||||
return getRunner(task).execute(task)
|
||||
const success = await getRunner(task).execute(task)
|
||||
if (success && task.isInstallationFix) {
|
||||
await refreshDesktopTasks()
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
|
||||
isRefreshing,
|
||||
isRunningTerminalCommand,
|
||||
isRunningInstallationFix,
|
||||
unsafeBasePath,
|
||||
unsafeBasePathReason,
|
||||
execute,
|
||||
getRunner,
|
||||
processUpdate,
|
||||
|
||||
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
159
apps/desktop-ui/src/views/MaintenanceView.stories.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
|
||||
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
|
||||
|
||||
type ValidationState = {
|
||||
inProgress: boolean
|
||||
installState: string
|
||||
basePath?: ValidationIssueState
|
||||
unsafeBasePath: boolean
|
||||
unsafeBasePathReason: UnsafeReason
|
||||
venvDirectory?: ValidationIssueState
|
||||
pythonInterpreter?: ValidationIssueState
|
||||
pythonPackages?: ValidationIssueState
|
||||
uv?: ValidationIssueState
|
||||
git?: ValidationIssueState
|
||||
vcRedist?: ValidationIssueState
|
||||
upgradePackages?: ValidationIssueState
|
||||
}
|
||||
|
||||
const validationState: ValidationState = {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
basePath: 'OK',
|
||||
unsafeBasePath: false,
|
||||
unsafeBasePathReason: null,
|
||||
venvDirectory: 'OK',
|
||||
pythonInterpreter: 'OK',
|
||||
pythonPackages: 'OK',
|
||||
uv: 'OK',
|
||||
git: 'OK',
|
||||
vcRedist: 'OK',
|
||||
upgradePackages: 'OK'
|
||||
}
|
||||
|
||||
const createMockElectronAPI = () => {
|
||||
const logListeners: Array<(message: string) => void> = []
|
||||
|
||||
const getValidationUpdate = () => ({
|
||||
...validationState
|
||||
})
|
||||
|
||||
return {
|
||||
getPlatform: () => 'darwin',
|
||||
changeTheme: (_theme: unknown) => {},
|
||||
onLogMessage: (listener: (message: string) => void) => {
|
||||
logListeners.push(listener)
|
||||
},
|
||||
showContextMenu: (_options: unknown) => {},
|
||||
Events: {
|
||||
trackEvent: (_eventName: string, _data?: unknown) => {}
|
||||
},
|
||||
Validation: {
|
||||
onUpdate: (_callback: (update: unknown) => void) => {},
|
||||
async getStatus() {
|
||||
return getValidationUpdate()
|
||||
},
|
||||
async validateInstallation(callback: (update: unknown) => void) {
|
||||
callback(getValidationUpdate())
|
||||
},
|
||||
async complete() {
|
||||
// Only allow completion when the base path is safe
|
||||
return !validationState.unsafeBasePath
|
||||
},
|
||||
dispose: () => {}
|
||||
},
|
||||
setBasePath: () => Promise.resolve(true),
|
||||
reinstall: () => Promise.resolve(),
|
||||
uv: {
|
||||
installRequirements: () => Promise.resolve(),
|
||||
clearCache: () => Promise.resolve(),
|
||||
resetVenv: () => Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensureElectronAPI = () => {
|
||||
const globalWindow = window as unknown as { electronAPI?: unknown }
|
||||
if (!globalWindow.electronAPI) {
|
||||
globalWindow.electronAPI = createMockElectronAPI()
|
||||
}
|
||||
|
||||
return globalWindow.electronAPI
|
||||
}
|
||||
|
||||
const MaintenanceView = defineAsyncComponent(async () => {
|
||||
ensureElectronAPI()
|
||||
const module = await import('./MaintenanceView.vue')
|
||||
return module.default
|
||||
})
|
||||
|
||||
const meta: Meta<typeof MaintenanceView> = {
|
||||
title: 'Desktop/Views/MaintenanceView',
|
||||
component: MaintenanceView,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
name: 'All tasks OK',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'OK'
|
||||
validationState.unsafeBasePath = false
|
||||
validationState.unsafeBasePathReason = null
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
|
||||
export const UnsafeBasePathOneDrive: Story = {
|
||||
name: 'Unsafe base path (OneDrive)',
|
||||
render: () => ({
|
||||
components: { MaintenanceView },
|
||||
setup() {
|
||||
validationState.inProgress = false
|
||||
validationState.installState = 'installed'
|
||||
validationState.basePath = 'error'
|
||||
validationState.unsafeBasePath = true
|
||||
validationState.unsafeBasePathReason = 'oneDrive'
|
||||
validationState.venvDirectory = 'OK'
|
||||
validationState.pythonInterpreter = 'OK'
|
||||
validationState.pythonPackages = 'OK'
|
||||
validationState.uv = 'OK'
|
||||
validationState.git = 'OK'
|
||||
validationState.vcRedist = 'OK'
|
||||
validationState.upgradePackages = 'OK'
|
||||
ensureElectronAPI()
|
||||
return {}
|
||||
},
|
||||
template: '<MaintenanceView />'
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unsafe migration warning -->
|
||||
<div v-if="taskStore.unsafeBasePath" class="my-4">
|
||||
<p class="flex items-start gap-3 text-neutral-300">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
<span>
|
||||
<strong class="block mb-1">
|
||||
{{ t('maintenance.unsafeMigration.title') }}
|
||||
</strong>
|
||||
<span class="block mb-1">
|
||||
{{ unsafeReasonText }}
|
||||
</span>
|
||||
<span class="block text-sm text-neutral-400">
|
||||
{{ t('maintenance.unsafeMigration.action') }}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<TaskListPanel
|
||||
class="border-neutral-700 border-solid border-x-0 border-y"
|
||||
@@ -89,10 +111,10 @@
|
||||
import { PrimeIcons } from '@primevue/core/api'
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Tag from 'primevue/tag'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import RefreshButton from '@/components/common/RefreshButton.vue'
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
@@ -139,6 +161,27 @@ const filterOptions = ref([
|
||||
/** Filter binding; can be set to show all tasks, or only errors. */
|
||||
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
|
||||
|
||||
const unsafeReasonText = computed(() => {
|
||||
const reason = taskStore.unsafeBasePathReason
|
||||
if (!reason) {
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
}
|
||||
|
||||
if (reason === 'appInstallDir') {
|
||||
return t('maintenance.unsafeMigration.appInstallDir')
|
||||
}
|
||||
|
||||
if (reason === 'updaterCache') {
|
||||
return t('maintenance.unsafeMigration.updaterCache')
|
||||
}
|
||||
|
||||
if (reason === 'oneDrive') {
|
||||
return t('maintenance.unsafeMigration.oneDrive')
|
||||
}
|
||||
|
||||
return t('maintenance.unsafeMigration.generic')
|
||||
})
|
||||
|
||||
/** If valid, leave the validation window. */
|
||||
const completeValidation = async () => {
|
||||
const isValid = await electron.Validation.complete()
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -1198,31 +1198,6 @@ 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
224
pnpm-lock.yaml
generated
@@ -69,6 +69,9 @@ 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
|
||||
@@ -498,6 +501,9 @@ 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)))
|
||||
@@ -2772,14 +2778,78 @@ 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'}
|
||||
@@ -3686,6 +3756,10 @@ 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'}
|
||||
@@ -4959,6 +5033,9 @@ 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}
|
||||
@@ -5039,6 +5116,10 @@ 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'}
|
||||
@@ -5168,6 +5249,10 @@ 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'}
|
||||
@@ -5853,6 +5938,10 @@ 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==}
|
||||
|
||||
@@ -6073,6 +6162,10 @@ 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'}
|
||||
@@ -6088,6 +6181,10 @@ 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'}
|
||||
@@ -6527,6 +6624,10 @@ 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==}
|
||||
|
||||
@@ -7426,6 +7527,9 @@ 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'}
|
||||
@@ -7699,6 +7803,13 @@ 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==}
|
||||
|
||||
@@ -10205,6 +10316,8 @@ 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
|
||||
@@ -10213,8 +10326,74 @@ 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
|
||||
@@ -11214,6 +11393,12 @@ 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:
|
||||
@@ -12713,6 +12898,8 @@ snapshots:
|
||||
jsonfile: 6.2.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
@@ -12807,6 +12994,13 @@ 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
|
||||
@@ -12937,6 +13131,13 @@ 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
|
||||
@@ -13626,6 +13827,10 @@ 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
|
||||
@@ -14031,6 +14236,10 @@ 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
|
||||
@@ -14045,6 +14254,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@4.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
minizlib@3.0.2:
|
||||
@@ -14550,6 +14761,8 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
promise@7.3.1:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
@@ -15689,6 +15902,13 @@ 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
|
||||
@@ -16037,6 +16257,10 @@ 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,6 +24,7 @@ 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
|
||||
|
||||
@@ -43,8 +43,10 @@ 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,
|
||||
@@ -61,8 +63,11 @@ 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(editable ? customAmount.value : amount)
|
||||
await authActions.purchaseCredits(creditAmount)
|
||||
loading.value = false
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ onMounted(() => {
|
||||
widget.options.selectOn ?? ['focus', 'click'],
|
||||
() => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
lgCanvas?.selectNode(widget.node)
|
||||
lgCanvas?.bringToFront(widget.node)
|
||||
lgCanvas?.selectNode(widgetState.widget.node)
|
||||
lgCanvas?.bringToFront(widgetState.widget.node)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="container" class="comfy-load-3d relative h-full w-full">
|
||||
<div ref="container" class="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="comfy-load-3d-viewer absolute h-full w-full"
|
||||
class="absolute h-full w-full"
|
||||
@resize="viewer.handleResize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
multiple
|
||||
:option-label="'display_name'"
|
||||
@complete="search($event.query)"
|
||||
@option-select="emit('addNode', $event.value)"
|
||||
@option-select="onAddNode($event.value)"
|
||||
@focused-option-changed="setHoverSuggestion($event)"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
@@ -78,6 +78,7 @@
|
||||
</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'
|
||||
@@ -88,6 +89,7 @@ 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'
|
||||
@@ -96,6 +98,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const enableNodePreview = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
||||
@@ -118,6 +121,14 @@ 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
|
||||
@@ -128,10 +139,22 @@ 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
|
||||
|
||||
@@ -5,7 +5,11 @@ 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 } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} 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'
|
||||
@@ -95,8 +99,12 @@ export function useSelectionToolboxPosition(
|
||||
} else {
|
||||
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
|
||||
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
|
||||
const bounds = item.getBounding()
|
||||
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
|
||||
allBounds.push([
|
||||
item.pos[0],
|
||||
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
|
||||
item.size[0],
|
||||
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } 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[]
|
||||
@@ -212,6 +214,38 @@ 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,4 +1,5 @@
|
||||
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -13,6 +14,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
|
||||
}
|
||||
|
||||
function show() {
|
||||
useTelemetry()?.trackTemplateLibraryOpened({ source: 'command' })
|
||||
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: WorkflowTemplateSelectorDialog,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
matchesPropertyItem,
|
||||
matchesWidgetItem,
|
||||
promoteWidget,
|
||||
pruneDisconnected,
|
||||
widgetItemToProperty
|
||||
} from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/proxyWidgetUtils'
|
||||
@@ -235,6 +236,7 @@ watchDebounced(
|
||||
)
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
|
||||
@@ -48,9 +48,12 @@ type Overlay = Partial<IBaseWidget> & {
|
||||
* on the linked widget
|
||||
*/
|
||||
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
|
||||
function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
|
||||
export 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
|
||||
@@ -167,7 +170,7 @@ function resolveLinkedWidget(
|
||||
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 instanceof disconnectedWidget.constructor && isProxyWidget(widget))
|
||||
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
|
||||
widget.computedHeight = 20
|
||||
return [n, widget]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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,
|
||||
@@ -163,3 +167,10 @@ 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])
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ 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()
|
||||
@@ -212,7 +213,14 @@ class Load3d {
|
||||
}
|
||||
|
||||
const contextmenuHandler = (e: MouseEvent) => {
|
||||
const wasDragging = this.rightMouseMoved
|
||||
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
|
||||
|
||||
|
||||
@@ -546,6 +546,7 @@ 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
|
||||
|
||||
@@ -140,6 +140,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
displayValue,
|
||||
// @ts-expect-error Prevent naming conflicts with custom nodes.
|
||||
labelBaseline,
|
||||
promoted,
|
||||
...safeValues
|
||||
} = widget
|
||||
|
||||
|
||||
@@ -1357,6 +1357,14 @@
|
||||
"taskFailed": "Task failed to run.",
|
||||
"cannotContinue": "Unable to continue - errors remain",
|
||||
"defaultDescription": "An error occurred while running a maintenance task."
|
||||
},
|
||||
"unsafeMigration": {
|
||||
"title": "Unsafe install location detected",
|
||||
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
|
||||
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
|
||||
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
|
||||
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
|
||||
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
|
||||
}
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
|
||||
@@ -37,6 +37,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isPolling = ref(false)
|
||||
@@ -62,6 +63,7 @@ const startPollingSubscriptionStatus = () => {
|
||||
|
||||
if (isActiveSubscription.value) {
|
||||
stopPolling()
|
||||
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||
emit('subscribed')
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,18 +3,31 @@ 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,
|
||||
TemplateMetadata
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -123,6 +136,10 @@ 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'
|
||||
@@ -132,6 +149,20 @@ 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()
|
||||
|
||||
@@ -153,7 +184,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
? TelemetryEvents.USER_SURVEY_OPENED
|
||||
: TelemetryEvents.USER_SURVEY_SUBMITTED
|
||||
|
||||
this.trackEvent(eventName, responses)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
@@ -178,6 +223,34 @@ 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)
|
||||
@@ -194,8 +267,28 @@ 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
|
||||
@@ -218,19 +311,22 @@ 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
|
||||
template_license: englishMetadata?.license ?? template?.license,
|
||||
...nodeMetrics
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: activeWorkflow.filename
|
||||
workflow_name: activeWorkflow.filename,
|
||||
...nodeMetrics
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_template: false,
|
||||
workflow_name: undefined
|
||||
workflow_name: undefined,
|
||||
...nodeMetrics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,15 +93,87 @@ 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
|
||||
@@ -108,6 +184,23 @@ 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
|
||||
@@ -125,11 +218,15 @@ 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',
|
||||
@@ -142,6 +239,23 @@ 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',
|
||||
@@ -163,3 +277,11 @@ export type TelemetryEventProperties =
|
||||
| RunButtonProperties
|
||||
| ExecutionErrorMetadata
|
||||
| ExecutionSuccessMetadata
|
||||
| CreditTopupMetadata
|
||||
| WorkflowImportMetadata
|
||||
| TemplateLibraryMetadata
|
||||
| PageVisibilityMetadata
|
||||
| TabCountMetadata
|
||||
| NodeSearchMetadata
|
||||
| NodeSearchResultMetadata
|
||||
| TemplateFilterMetadata
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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'
|
||||
@@ -1271,6 +1272,14 @@ 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
|
||||
|
||||
@@ -61,7 +61,7 @@ function getSubgraphsFromInstanceIds(
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] {
|
||||
): Subgraph[] | undefined {
|
||||
// Last segment is the node portion; nothing to do.
|
||||
if (subgraphNodeIds.length === 1) return subgraphs
|
||||
|
||||
@@ -69,7 +69,10 @@ function getSubgraphsFromInstanceIds(
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
|
||||
if (!subgraph) {
|
||||
console.warn(`Subgraph not found: ${currentPart}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
@@ -80,7 +83,9 @@ function getSubgraphsFromInstanceIds(
|
||||
* @param nodeId The node ID from execution context (could be execution ID)
|
||||
* @returns The NodeLocatorId
|
||||
*/
|
||||
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
|
||||
function executionIdToNodeLocatorId(
|
||||
nodeId: string | number
|
||||
): NodeLocatorId | undefined {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
@@ -92,6 +97,7 @@ function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -463,6 +463,27 @@ 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
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,9 @@ 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'
|
||||
@@ -59,6 +61,7 @@ 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'
|
||||
@@ -87,6 +90,13 @@ 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) => {
|
||||
@@ -250,6 +260,22 @@ 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)
|
||||
@@ -268,6 +294,61 @@ 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)()
|
||||
|
||||
@@ -156,14 +156,11 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
it('should return undefined when conversion fails', () => {
|
||||
// Mock app.graph.getNodeById to return null (node not found)
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
|
||||
|
||||
// This should throw an error as the node is not found
|
||||
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
|
||||
'Subgraph not found: 999'
|
||||
)
|
||||
expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ 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,3 +1,4 @@
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
@@ -289,6 +290,27 @@ 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']
|
||||
}
|
||||
})
|
||||
]
|
||||
: [])
|
||||
],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user