mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 21:21:06 +00:00
Compare commits
7 Commits
codex/clou
...
cloud/trac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
846c90bd8f | ||
|
|
b423d6239b | ||
|
|
09f2bd6e9b | ||
|
|
8932b42480 | ||
|
|
8c3116d6ff | ||
|
|
a3b7417384 | ||
|
|
0c04f00da0 |
234
scripts/survey-data-migration.ts
Normal file
234
scripts/survey-data-migration.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Survey Data Migration Script
|
||||
*
|
||||
* One-time utility to normalize existing Mixpanel user properties
|
||||
* for industry and use case fields. This addresses the proliferation
|
||||
* of one-off categories that make analytics difficult.
|
||||
*
|
||||
* Usage: pnpm ts-node scripts/survey-data-migration.ts
|
||||
*
|
||||
* IMPORTANT: This script requires Mixpanel Data Management API access
|
||||
* and should be run with appropriate credentials in production.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import {
|
||||
normalizeIndustry,
|
||||
normalizeUseCase
|
||||
} from '../src/platform/telemetry/utils/surveyNormalization'
|
||||
|
||||
interface MixpanelUser {
|
||||
$distinct_id: string
|
||||
$properties: {
|
||||
industry?: string
|
||||
useCase?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
interface MigrationStats {
|
||||
totalUsers: number
|
||||
industryNormalized: number
|
||||
useCaseNormalized: number
|
||||
uncategorizedIndustries: Set<string>
|
||||
uncategorizedUseCases: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the data migration process
|
||||
* In production, this would integrate with Mixpanel Data Management API
|
||||
*/
|
||||
function simulateMigration(users: MixpanelUser[]): MigrationStats {
|
||||
const stats: MigrationStats = {
|
||||
totalUsers: users.length,
|
||||
industryNormalized: 0,
|
||||
useCaseNormalized: 0,
|
||||
uncategorizedIndustries: new Set<string>(),
|
||||
uncategorizedUseCases: new Set<string>()
|
||||
}
|
||||
|
||||
users.forEach((user) => {
|
||||
let needsUpdate = false
|
||||
const updates: Record<string, any> = {}
|
||||
|
||||
// Process industry normalization
|
||||
if (user.$properties.industry) {
|
||||
const normalized = normalizeIndustry(user.$properties.industry)
|
||||
|
||||
if (normalized !== user.$properties.industry) {
|
||||
updates.industry_normalized = normalized
|
||||
updates.industry_raw = user.$properties.industry
|
||||
stats.industryNormalized++
|
||||
needsUpdate = true
|
||||
|
||||
if (normalized.startsWith('Uncategorized:')) {
|
||||
stats.uncategorizedIndustries.add(user.$properties.industry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process use case normalization
|
||||
if (user.$properties.useCase) {
|
||||
const normalized = normalizeUseCase(user.$properties.useCase)
|
||||
|
||||
if (normalized !== user.$properties.useCase) {
|
||||
updates.useCase_normalized = normalized
|
||||
updates.useCase_raw = user.$properties.useCase
|
||||
stats.useCaseNormalized++
|
||||
needsUpdate = true
|
||||
|
||||
if (normalized.startsWith('Uncategorized:')) {
|
||||
stats.uncategorizedUseCases.add(user.$properties.useCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In production, this would make API calls to update user properties
|
||||
if (needsUpdate) {
|
||||
console.log(`Would update user ${user.$distinct_id}:`, updates)
|
||||
}
|
||||
})
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sample data for testing normalization rules
|
||||
*/
|
||||
function generateSampleData(): MixpanelUser[] {
|
||||
return [
|
||||
{
|
||||
$distinct_id: 'user1',
|
||||
$properties: {
|
||||
industry: 'Film and television production',
|
||||
useCase: 'Creating concept art for movies'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user2',
|
||||
$properties: {
|
||||
industry: 'Marketing & Social Media',
|
||||
useCase: 'YouTube thumbnail generation'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user3',
|
||||
$properties: {
|
||||
industry: 'Software Development',
|
||||
useCase: 'Product mockup creation'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user4',
|
||||
$properties: {
|
||||
industry: 'Indie Game Studio',
|
||||
useCase: 'Game asset generation'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user5',
|
||||
$properties: {
|
||||
industry: 'Architecture firm',
|
||||
useCase: 'Building visualization'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user6',
|
||||
$properties: {
|
||||
industry: 'Custom Jewelry Design',
|
||||
useCase: 'Product photography'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user7',
|
||||
$properties: {
|
||||
industry: 'Medical Research',
|
||||
useCase: 'Scientific visualization'
|
||||
}
|
||||
},
|
||||
{
|
||||
$distinct_id: 'user8',
|
||||
$properties: {
|
||||
industry: 'Unknown Creative Field',
|
||||
useCase: 'Personal art projects'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Production implementation would use Mixpanel Data Management API
|
||||
* Example API structure (not actual implementation):
|
||||
*/
|
||||
async function productionMigration() {
|
||||
console.log('🔧 Production Migration Process:')
|
||||
console.log('1. Export user profiles via Mixpanel Data Management API')
|
||||
console.log('2. Apply normalization to industry and useCase fields')
|
||||
console.log(
|
||||
'3. Create new properties: industry_normalized, useCase_normalized'
|
||||
)
|
||||
console.log('4. Preserve original values as: industry_raw, useCase_raw')
|
||||
console.log('5. Batch update user profiles')
|
||||
console.log('6. Generate uncategorized response report for review')
|
||||
|
||||
/*
|
||||
Example API calls:
|
||||
|
||||
// 1. Export users
|
||||
const users = await mixpanel.people.query({
|
||||
where: 'properties["industry"] != null or properties["useCase"] != null'
|
||||
})
|
||||
|
||||
// 2. Process and update
|
||||
for (const user of users) {
|
||||
const normalizedData = normalizeSurveyResponses(user.properties)
|
||||
await mixpanel.people.set(user.distinct_id, normalizedData)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Main migration runner
|
||||
*/
|
||||
function main() {
|
||||
console.log('📊 Survey Data Migration Utility')
|
||||
console.log('================================\n')
|
||||
|
||||
// Run simulation with sample data
|
||||
console.log('🧪 Running simulation with sample data...\n')
|
||||
const sampleUsers = generateSampleData()
|
||||
const stats = simulateMigration(sampleUsers)
|
||||
|
||||
// Display results
|
||||
console.log('📈 Migration Results:')
|
||||
console.log(`Total users processed: ${stats.totalUsers}`)
|
||||
console.log(`Industry fields normalized: ${stats.industryNormalized}`)
|
||||
console.log(`Use case fields normalized: ${stats.useCaseNormalized}`)
|
||||
|
||||
if (stats.uncategorizedIndustries.size > 0) {
|
||||
console.log('\n❓ Uncategorized Industries (need review):')
|
||||
Array.from(stats.uncategorizedIndustries).forEach((industry) => {
|
||||
console.log(` • ${industry}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (stats.uncategorizedUseCases.size > 0) {
|
||||
console.log('\n❓ Uncategorized Use Cases (need review):')
|
||||
Array.from(stats.uncategorizedUseCases).forEach((useCase) => {
|
||||
console.log(` • ${useCase}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50))
|
||||
void productionMigration()
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
|
||||
export { simulateMigration, generateSampleData, MigrationStats }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,238 @@
|
||||
/**
|
||||
* Survey Normalization Tests
|
||||
*
|
||||
* Tests fuzzy matching of free-text survey responses into standard categories
|
||||
* using Fuse.js. Tests verify correct categorization, Mixpanel property structure,
|
||||
* and that unknown inputs don't force-categorize.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeIndustry,
|
||||
normalizeUseCase,
|
||||
normalizeSurveyResponses
|
||||
} from '../surveyNormalization'
|
||||
|
||||
describe('normalizeSurveyResponses - Mixpanel Integration', () => {
|
||||
it('should create normalized and raw fields for Mixpanel user properties', () => {
|
||||
const result = normalizeSurveyResponses({
|
||||
industry: 'Software Development',
|
||||
useCase: 'product design'
|
||||
})
|
||||
|
||||
expect(result.industry_normalized).toBe('Software / IT / AI')
|
||||
expect(result.useCase_normalized).toBe('Product Visualization & Design')
|
||||
expect(result.industry_raw).toBe('Software Development')
|
||||
expect(result.useCase_raw).toBe('product design')
|
||||
})
|
||||
|
||||
it('should preserve all other survey fields unchanged', () => {
|
||||
const result = normalizeSurveyResponses({
|
||||
industry: 'Film',
|
||||
useCase: 'animation',
|
||||
familiarity: 'expert',
|
||||
making: ['videos', 'images']
|
||||
})
|
||||
|
||||
expect(result.familiarity).toBe('expert')
|
||||
expect(result.making).toEqual(['videos', 'images'])
|
||||
expect(result.industry_normalized).toBe('Film / TV / Animation')
|
||||
})
|
||||
|
||||
it('should handle partial responses gracefully', () => {
|
||||
const result = normalizeSurveyResponses({
|
||||
industry: 'Marketing'
|
||||
})
|
||||
|
||||
expect(result.industry_normalized).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(result.useCase_normalized).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle completely empty responses', () => {
|
||||
const result = normalizeSurveyResponses({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle responses where both fields are undefined', () => {
|
||||
const result = normalizeSurveyResponses({
|
||||
industry: undefined,
|
||||
useCase: 'other'
|
||||
})
|
||||
|
||||
expect(result.industry_normalized).toBeUndefined()
|
||||
expect(result.useCase_normalized).toBe('Other / Undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndustry - Common Inputs', () => {
|
||||
it('should categorize exact or near-exact category names', () => {
|
||||
expect(normalizeIndustry('Film and television production')).toBe(
|
||||
'Film / TV / Animation'
|
||||
)
|
||||
expect(normalizeIndustry('Marketing & Social Media')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('Software Development')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('Indie Game Studio')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
})
|
||||
|
||||
it('should categorize strong unambiguous keywords', () => {
|
||||
expect(normalizeIndustry('animation')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('biotech')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
expect(normalizeIndustry('nonprofit')).toBe(
|
||||
'Nonprofit / Government / Public Sector'
|
||||
)
|
||||
expect(normalizeIndustry('game development')).toBe(
|
||||
'Gaming / Interactive Media'
|
||||
)
|
||||
})
|
||||
|
||||
it('should categorize common multi-word industry phrases', () => {
|
||||
expect(normalizeIndustry('digital marketing')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
expect(normalizeIndustry('web development')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('Architecture firm')).toBe(
|
||||
'Architecture / Engineering / Construction'
|
||||
)
|
||||
expect(normalizeIndustry('fashion design')).toBe(
|
||||
'Fashion / Beauty / Retail'
|
||||
)
|
||||
expect(normalizeIndustry('medical research')).toBe(
|
||||
'Healthcare / Medical / Life Science'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUseCase - Common Inputs', () => {
|
||||
it('should categorize common use case phrases', () => {
|
||||
expect(normalizeUseCase('content creation')).toBe(
|
||||
'Content Creation & Marketing'
|
||||
)
|
||||
expect(normalizeUseCase('concept art')).toBe('Art & Illustration')
|
||||
expect(normalizeUseCase('product visualization')).toBe(
|
||||
'Product Visualization & Design'
|
||||
)
|
||||
})
|
||||
|
||||
it('should categorize strong unambiguous keywords', () => {
|
||||
expect(normalizeUseCase('marketing')).toBe('Content Creation & Marketing')
|
||||
expect(normalizeUseCase('photography')).toBe(
|
||||
'Photography & Image Processing'
|
||||
)
|
||||
expect(normalizeUseCase('architecture')).toBe('Architecture & Construction')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndustry - Fuzzy Matching Behavior', () => {
|
||||
it('should handle typos and still categorize', () => {
|
||||
const result = normalizeIndustry('animtion')
|
||||
|
||||
expect(result).not.toBe('Other / Undefined')
|
||||
expect(result).not.toMatch(/^Uncategorized:/)
|
||||
})
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(normalizeIndustry('FILM PRODUCTION')).toBe('Film / TV / Animation')
|
||||
expect(normalizeIndustry('software development')).toBe('Software / IT / AI')
|
||||
expect(normalizeIndustry('MaRkEtInG')).toBe(
|
||||
'Marketing / Advertising / Social Media'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle abbreviations and shorthand', () => {
|
||||
const results = [
|
||||
normalizeIndustry('AI research'),
|
||||
normalizeIndustry('movie production'),
|
||||
normalizeIndustry('tech startup')
|
||||
]
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result).not.toBe('Other / Undefined')
|
||||
expect(result).not.toMatch(/^Uncategorized:/)
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept reasonable ambiguity for overlapping keywords', () => {
|
||||
const result = normalizeIndustry('content creation')
|
||||
expect([
|
||||
'Marketing / Advertising / Social Media',
|
||||
'Fine Art / Contemporary Art'
|
||||
]).toContain(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUseCase - Fuzzy Matching Behavior', () => {
|
||||
it('should handle variations and related terms', () => {
|
||||
const result = normalizeUseCase('social media posts')
|
||||
expect(result).toBe('Content Creation & Marketing')
|
||||
})
|
||||
|
||||
it('should categorize by finding keyword matches', () => {
|
||||
const results = [
|
||||
normalizeUseCase('YouTube thumbnails'),
|
||||
normalizeUseCase('product mockups'),
|
||||
normalizeUseCase('building renderings')
|
||||
]
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result).not.toBe('Other / Undefined')
|
||||
expect(result).not.toMatch(/^Uncategorized:/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndustry - Edge Cases', () => {
|
||||
describe('Empty inputs', () => {
|
||||
it('should handle empty strings and whitespace', () => {
|
||||
expect(normalizeIndustry('')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry(' ')).toBe('Other / Undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Placeholder responses', () => {
|
||||
it('should recognize common skip/placeholder values', () => {
|
||||
expect(normalizeIndustry('other')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('n/a')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('none')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('unknown')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('not applicable')).toBe('Other / Undefined')
|
||||
expect(normalizeIndustry('-')).toBe('Other / Undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unknown inputs preservation', () => {
|
||||
it('should preserve truly unknown inputs with Uncategorized prefix', () => {
|
||||
const input = 'Completely Novel Field That Does Not Match'
|
||||
const result = normalizeIndustry(input)
|
||||
|
||||
expect(result).toBe(`Uncategorized: ${input}`)
|
||||
})
|
||||
|
||||
it('should not force-categorize inputs with no keyword matches', () => {
|
||||
const result = normalizeIndustry('Underwater Basket Weaving Federation')
|
||||
|
||||
expect(result).toMatch(/^Uncategorized:/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeUseCase - Edge Cases', () => {
|
||||
it('should handle empty strings and placeholder values', () => {
|
||||
expect(normalizeUseCase('')).toBe('Other / Undefined')
|
||||
expect(normalizeUseCase('other')).toBe('Other / Undefined')
|
||||
})
|
||||
|
||||
it('should preserve unknown use cases with Uncategorized prefix', () => {
|
||||
const input = 'Mysterious Novel Use Case'
|
||||
const result = normalizeUseCase(input)
|
||||
expect(result).toBe(`Uncategorized: ${input}`)
|
||||
})
|
||||
})
|
||||
533
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
533
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* 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',
|
||||
'story',
|
||||
'anime',
|
||||
'video',
|
||||
'cinematography',
|
||||
'visual effects',
|
||||
'vfx',
|
||||
'movie',
|
||||
'cinema',
|
||||
'documentary',
|
||||
'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',
|
||||
'it',
|
||||
'ai',
|
||||
'developer',
|
||||
'consulting',
|
||||
'engineering',
|
||||
'tech',
|
||||
'programmer',
|
||||
'data science',
|
||||
'machine learning',
|
||||
'coding',
|
||||
'programming',
|
||||
'web development',
|
||||
'app development',
|
||||
'saas',
|
||||
'startup'
|
||||
]
|
||||
},
|
||||
{
|
||||
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',
|
||||
'learning',
|
||||
'university',
|
||||
'school',
|
||||
'academic',
|
||||
'professor',
|
||||
'curriculum',
|
||||
'training',
|
||||
'instruction',
|
||||
'pedagogy'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Architecture / Engineering / Construction',
|
||||
userCount: 420,
|
||||
keywords: [
|
||||
'architecture',
|
||||
'construction',
|
||||
'engineering',
|
||||
'civil',
|
||||
'cad',
|
||||
'building',
|
||||
'structural',
|
||||
'landscape',
|
||||
'interior design',
|
||||
'real estate',
|
||||
'planning',
|
||||
'blueprints'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Gaming / Interactive Media',
|
||||
userCount: 410,
|
||||
keywords: [
|
||||
'gaming',
|
||||
'game dev',
|
||||
'game development',
|
||||
'roblox',
|
||||
'interactive',
|
||||
'virtual world',
|
||||
'vr',
|
||||
'ar',
|
||||
'metaverse',
|
||||
'simulation',
|
||||
'unity',
|
||||
'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',
|
||||
'jewelry',
|
||||
'retail',
|
||||
'style',
|
||||
'clothing',
|
||||
'cosmetics',
|
||||
'makeup',
|
||||
'accessories',
|
||||
'boutique',
|
||||
'ecommerce'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Music / Performing Arts',
|
||||
userCount: 25,
|
||||
keywords: [
|
||||
'music',
|
||||
'vj',
|
||||
'dance',
|
||||
'projection mapping',
|
||||
'audio visual',
|
||||
'concert',
|
||||
'performance',
|
||||
'theater',
|
||||
'stage',
|
||||
'live events'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Healthcare / Medical / Life Science',
|
||||
userCount: 30,
|
||||
keywords: [
|
||||
'healthcare',
|
||||
'medical',
|
||||
'doctor',
|
||||
'biotech',
|
||||
'life science',
|
||||
'pharmaceutical',
|
||||
'clinical',
|
||||
'hospital',
|
||||
'medicine',
|
||||
'health'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'E-commerce / Print-on-Demand / Business',
|
||||
userCount: 15,
|
||||
keywords: [
|
||||
'ecommerce',
|
||||
'print on demand',
|
||||
'shop',
|
||||
'business',
|
||||
'commercial',
|
||||
'startup',
|
||||
'entrepreneur',
|
||||
'sales',
|
||||
'retail',
|
||||
'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', 'adult', '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',
|
||||
'advertising',
|
||||
'youtube',
|
||||
'tiktok',
|
||||
'instagram',
|
||||
'thumbnails',
|
||||
'posts',
|
||||
'campaigns',
|
||||
'brand content'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Art & Illustration',
|
||||
keywords: [
|
||||
'art',
|
||||
'illustration',
|
||||
'drawing',
|
||||
'painting',
|
||||
'concept art',
|
||||
'character design',
|
||||
'digital art',
|
||||
'fantasy art',
|
||||
'portraits'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Product Visualization & Design',
|
||||
keywords: [
|
||||
'product',
|
||||
'visualization',
|
||||
'design',
|
||||
'prototype',
|
||||
'mockup',
|
||||
'3d rendering',
|
||||
'industrial design',
|
||||
'product photos'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Film & Video Production',
|
||||
keywords: [
|
||||
'film',
|
||||
'video',
|
||||
'movie',
|
||||
'animation',
|
||||
'vfx',
|
||||
'visual effects',
|
||||
'storyboard',
|
||||
'cinematography',
|
||||
'post production'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Gaming & Interactive Media',
|
||||
keywords: [
|
||||
'game',
|
||||
'gaming',
|
||||
'interactive',
|
||||
'vr',
|
||||
'ar',
|
||||
'virtual',
|
||||
'simulation',
|
||||
'metaverse',
|
||||
'game assets',
|
||||
'textures'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Architecture & Construction',
|
||||
keywords: [
|
||||
'architecture',
|
||||
'building',
|
||||
'construction',
|
||||
'interior design',
|
||||
'landscape',
|
||||
'real estate',
|
||||
'floor plans',
|
||||
'renderings'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Education & Training',
|
||||
keywords: [
|
||||
'education',
|
||||
'training',
|
||||
'learning',
|
||||
'teaching',
|
||||
'tutorial',
|
||||
'course',
|
||||
'academic',
|
||||
'instructional',
|
||||
'workshops'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Research & Development',
|
||||
keywords: [
|
||||
'research',
|
||||
'development',
|
||||
'experiment',
|
||||
'prototype',
|
||||
'testing',
|
||||
'analysis',
|
||||
'study',
|
||||
'innovation',
|
||||
'r&d'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Personal & Hobby',
|
||||
keywords: [
|
||||
'personal',
|
||||
'hobby',
|
||||
'fun',
|
||||
'experiment',
|
||||
'learning',
|
||||
'curiosity',
|
||||
'explore',
|
||||
'creative',
|
||||
'side project'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Photography & Image Processing',
|
||||
keywords: [
|
||||
'photography',
|
||||
'photo',
|
||||
'image',
|
||||
'portrait',
|
||||
'editing',
|
||||
'enhancement',
|
||||
'restoration',
|
||||
'photo manipulation'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Fuse.js configuration for category matching
|
||||
*/
|
||||
const FUSE_OPTIONS = {
|
||||
keys: ['keywords'],
|
||||
threshold: 0.65, // Higher = more lenient matching
|
||||
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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,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'
|
||||
@@ -57,6 +59,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'
|
||||
@@ -85,6 +88,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) => {
|
||||
@@ -248,6 +258,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)
|
||||
@@ -266,6 +292,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)()
|
||||
|
||||
Reference in New Issue
Block a user