Cloud/tracking v2 (#6400)

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6400-Cloud-tracking-v2-29c6d73d365081a1ae32e9337f510a9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Arjan Singh <arjan@comfy.org>
This commit is contained in:
Christian Byrne
2025-10-30 20:46:42 -07:00
committed by GitHub
parent 6491db6f68
commit 1322a56653
13 changed files with 1698 additions and 8 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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)
})
})
})
})

View 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
}

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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)()

View File

@@ -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: {