mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +00:00
[rh-test] Telemetry Backports (#6522)
## Summary Resolves issues with #6503 ## Changes - Backport #6400 - Fix circular dependency issue - Backport #6505 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6522-rh-test-Telemetry-Backports-29e6d73d365081258d10c08299bde69b) by [Unito](https://www.unito.io) --------- Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: Christian Byrne <chrbyrne96@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -158,9 +158,7 @@ const queuePrompt = async (e: Event) => {
|
|||||||
? 'Comfy.QueuePromptFront'
|
? 'Comfy.QueuePromptFront'
|
||||||
: 'Comfy.QueuePrompt'
|
: 'Comfy.QueuePrompt'
|
||||||
|
|
||||||
if (isCloud) {
|
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
||||||
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
await commandStore.execute(commandId)
|
await commandStore.execute(commandId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
@@ -92,12 +93,18 @@ const showReport = () => {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const systemStatsStore = useSystemStatsStore()
|
const systemStatsStore = useSystemStatsStore()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const title = computed<string>(
|
const title = computed<string>(
|
||||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||||
)
|
)
|
||||||
|
|
||||||
const showContactSupport = async () => {
|
const showContactSupport = async () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'help_feedback',
|
||||||
|
is_external: true,
|
||||||
|
source: 'error_dialog'
|
||||||
|
})
|
||||||
await useCommandStore().execute('Comfy.ContactSupport')
|
await useCommandStore().execute('Comfy.ContactSupport')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ import Tag from 'primevue/tag'
|
|||||||
import { onBeforeUnmount, ref } from 'vue'
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
|
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
amount,
|
amount,
|
||||||
@@ -61,8 +63,11 @@ const didClickBuyNow = ref(false)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const handleBuyNow = async () => {
|
const handleBuyNow = async () => {
|
||||||
|
const creditAmount = editable ? customAmount.value : amount
|
||||||
|
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await authActions.purchaseCredits(editable ? customAmount.value : amount)
|
await authActions.purchaseCredits(creditAmount)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
didClickBuyNow.value = true
|
didClickBuyNow.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import UserCredit from '@/components/common/UserCredit.vue'
|
import UserCredit from '@/components/common/UserCredit.vue'
|
||||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
@@ -139,6 +140,7 @@ const dialogService = useDialogService()
|
|||||||
const authStore = useFirebaseAuthStore()
|
const authStore = useFirebaseAuthStore()
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
const loading = computed(() => authStore.loading)
|
const loading = computed(() => authStore.loading)
|
||||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||||
|
|
||||||
@@ -168,6 +170,11 @@ const handleCreditsHistoryClick = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMessageSupport = async () => {
|
const handleMessageSupport = async () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'help_feedback',
|
||||||
|
is_external: true,
|
||||||
|
source: 'credits_panel'
|
||||||
|
})
|
||||||
await commandStore.execute('Comfy.ContactSupport')
|
await commandStore.execute('Comfy.ContactSupport')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,13 +130,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import type { CSSProperties, Component } from 'vue'
|
import type { CSSProperties, Component } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
|
||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
@@ -188,6 +189,7 @@ const { t, locale } = useI18n()
|
|||||||
const releaseStore = useReleaseStore()
|
const releaseStore = useReleaseStore()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -199,6 +201,7 @@ const isSubmenuVisible = ref(false)
|
|||||||
const submenuRef = ref<HTMLElement | null>(null)
|
const submenuRef = ref<HTMLElement | null>(null)
|
||||||
const submenuStyle = ref<CSSProperties>({})
|
const submenuStyle = ref<CSSProperties>({})
|
||||||
let hoverTimeout: number | null = null
|
let hoverTimeout: number | null = null
|
||||||
|
const openedAt = ref<number>(Date.now())
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||||
@@ -218,6 +221,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
|||||||
label: t('helpCenter.desktopUserGuide'),
|
label: t('helpCenter.desktopUserGuide'),
|
||||||
visible: isElectron(),
|
visible: isElectron(),
|
||||||
action: () => {
|
action: () => {
|
||||||
|
trackResourceClick('docs', true)
|
||||||
const docsUrl =
|
const docsUrl =
|
||||||
electronAPI().getPlatform() === 'darwin'
|
electronAPI().getPlatform() === 'darwin'
|
||||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||||
@@ -273,6 +277,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
icon: 'pi pi-book',
|
icon: 'pi pi-book',
|
||||||
label: t('helpCenter.docs'),
|
label: t('helpCenter.docs'),
|
||||||
action: () => {
|
action: () => {
|
||||||
|
trackResourceClick('docs', true)
|
||||||
openExternalLink(EXTERNAL_LINKS.DOCS)
|
openExternalLink(EXTERNAL_LINKS.DOCS)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -283,6 +288,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
icon: 'pi pi-discord',
|
icon: 'pi pi-discord',
|
||||||
label: 'Discord',
|
label: 'Discord',
|
||||||
action: () => {
|
action: () => {
|
||||||
|
trackResourceClick('discord', true)
|
||||||
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
openExternalLink(EXTERNAL_LINKS.DISCORD)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -293,6 +299,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
icon: 'pi pi-github',
|
icon: 'pi pi-github',
|
||||||
label: t('helpCenter.github'),
|
label: t('helpCenter.github'),
|
||||||
action: () => {
|
action: () => {
|
||||||
|
trackResourceClick('github', true)
|
||||||
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
openExternalLink(EXTERNAL_LINKS.GITHUB)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -303,6 +310,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
icon: 'pi pi-question-circle',
|
icon: 'pi pi-question-circle',
|
||||||
label: t('helpCenter.helpFeedback'),
|
label: t('helpCenter.helpFeedback'),
|
||||||
action: () => {
|
action: () => {
|
||||||
|
trackResourceClick('help_feedback', false)
|
||||||
void commandStore.execute('Comfy.ContactSupport')
|
void commandStore.execute('Comfy.ContactSupport')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -318,6 +326,7 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
label: t('helpCenter.managerExtension'),
|
label: t('helpCenter.managerExtension'),
|
||||||
showRedDot: shouldShowManagerRedDot.value,
|
showRedDot: shouldShowManagerRedDot.value,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
|
trackResourceClick('manager', false)
|
||||||
await useManagerState().openManager({
|
await useManagerState().openManager({
|
||||||
initialTab: ManagerTab.All,
|
initialTab: ManagerTab.All,
|
||||||
showToastOnLegacyError: false
|
showToastOnLegacyError: false
|
||||||
@@ -341,6 +350,23 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Utility Functions
|
// Utility Functions
|
||||||
|
const trackResourceClick = (
|
||||||
|
resourceType:
|
||||||
|
| 'docs'
|
||||||
|
| 'discord'
|
||||||
|
| 'github'
|
||||||
|
| 'help_feedback'
|
||||||
|
| 'manager'
|
||||||
|
| 'release_notes',
|
||||||
|
isExternal: boolean
|
||||||
|
): void => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: resourceType,
|
||||||
|
is_external: isExternal,
|
||||||
|
source: 'help_center'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openExternalLink = (url: string): void => {
|
const openExternalLink = (url: string): void => {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
@@ -496,6 +522,7 @@ const onReinstall = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onReleaseClick = (release: ReleaseNote): void => {
|
const onReleaseClick = (release: ReleaseNote): void => {
|
||||||
|
trackResourceClick('release_notes', true)
|
||||||
void releaseStore.handleShowChangelog(release.version)
|
void releaseStore.handleShowChangelog(release.version)
|
||||||
const versionAnchor = formatVersionAnchor(release.version)
|
const versionAnchor = formatVersionAnchor(release.version)
|
||||||
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
|
||||||
@@ -504,6 +531,7 @@ const onReleaseClick = (release: ReleaseNote): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onUpdate = (_: ReleaseNote): void => {
|
const onUpdate = (_: ReleaseNote): void => {
|
||||||
|
trackResourceClick('docs', true)
|
||||||
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
@@ -518,10 +546,16 @@ const getChangelogUrl = (): string => {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })
|
||||||
if (!hasReleases.value) {
|
if (!hasReleases.value) {
|
||||||
await releaseStore.fetchReleases()
|
await releaseStore.fetchReleases()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const timeSpentSeconds = Math.round((Date.now() - openedAt.value) / 1000)
|
||||||
|
telemetry?.trackHelpCenterClosed({ time_spent_seconds: timeSpentSeconds })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
multiple
|
multiple
|
||||||
:option-label="'display_name'"
|
:option-label="'display_name'"
|
||||||
@complete="search($event.query)"
|
@complete="search($event.query)"
|
||||||
@option-select="emit('addNode', $event.value)"
|
@option-select="onAddNode($event.value)"
|
||||||
@focused-option-changed="setHoverSuggestion($event)"
|
@focused-option-changed="setHoverSuggestion($event)"
|
||||||
>
|
>
|
||||||
<template #option="{ option }">
|
<template #option="{ option }">
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { debounce } from 'es-toolkit/compat'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
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 NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||||
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||||
@@ -96,6 +98,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
|
|||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const enableNodePreview = computed(() =>
|
const enableNodePreview = computed(() =>
|
||||||
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
|
||||||
@@ -117,6 +120,14 @@ const placeholder = computed(() => {
|
|||||||
|
|
||||||
const nodeDefStore = useNodeDefStore()
|
const nodeDefStore = useNodeDefStore()
|
||||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
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 search = (query: string) => {
|
||||||
const queryIsEmpty = query === '' && filters.length === 0
|
const queryIsEmpty = query === '' && filters.length === 0
|
||||||
currentQuery.value = query
|
currentQuery.value = query
|
||||||
@@ -127,10 +138,22 @@ const search = (query: string) => {
|
|||||||
limit: searchLimit
|
limit: searchLimit
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Track search queries with debounce
|
||||||
|
debouncedTrackSearch(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
|
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
|
let inputElement: HTMLInputElement | null = null
|
||||||
const reFocusInput = async () => {
|
const reFocusInput = async () => {
|
||||||
inputElement ??= document.getElementById(inputId) as HTMLInputElement
|
inputElement ??= document.getElementById(inputId) as HTMLInputElement
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
|
|||||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { SUPPORT_URL } from '@/platform/support/config'
|
import { SUPPORT_URL } from '@/platform/support/config'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
@@ -73,6 +72,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const bottomPanelStore = useBottomPanelStore()
|
const bottomPanelStore = useBottomPanelStore()
|
||||||
|
|
||||||
@@ -101,7 +101,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
label: 'New Blank Workflow',
|
label: 'New Blank Workflow',
|
||||||
menubarLabel: 'New',
|
menubarLabel: 'New',
|
||||||
category: 'essentials' as const,
|
category: 'essentials' as const,
|
||||||
function: () => workflowService.loadBlankWorkflow()
|
function: async () => {
|
||||||
|
const previousWorkflowHadNodes = app.graph._nodes.length > 0
|
||||||
|
await workflowService.loadBlankWorkflow()
|
||||||
|
telemetry?.trackWorkflowCreated({
|
||||||
|
workflow_type: 'blank',
|
||||||
|
previous_workflow_had_nodes: previousWorkflowHadNodes
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Comfy.OpenWorkflow',
|
id: 'Comfy.OpenWorkflow',
|
||||||
@@ -117,7 +124,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
id: 'Comfy.LoadDefaultWorkflow',
|
id: 'Comfy.LoadDefaultWorkflow',
|
||||||
icon: 'pi pi-code',
|
icon: 'pi pi-code',
|
||||||
label: 'Load Default Workflow',
|
label: 'Load Default Workflow',
|
||||||
function: () => workflowService.loadDefaultWorkflow()
|
function: async () => {
|
||||||
|
const previousWorkflowHadNodes = app.graph._nodes.length > 0
|
||||||
|
await workflowService.loadDefaultWorkflow()
|
||||||
|
telemetry?.trackWorkflowCreated({
|
||||||
|
workflow_type: 'default',
|
||||||
|
previous_workflow_had_nodes: previousWorkflowHadNodes
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Comfy.SaveWorkflow',
|
id: 'Comfy.SaveWorkflow',
|
||||||
@@ -459,9 +473,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
|
|
||||||
const batchCount = useQueueSettingsStore().batchCount
|
const batchCount = useQueueSettingsStore().batchCount
|
||||||
|
|
||||||
if (isCloud) {
|
useTelemetry()?.trackWorkflowExecution()
|
||||||
useTelemetry()?.trackWorkflowExecution()
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.queuePrompt(0, batchCount)
|
await app.queuePrompt(0, batchCount)
|
||||||
}
|
}
|
||||||
@@ -480,9 +492,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
|
|
||||||
const batchCount = useQueueSettingsStore().batchCount
|
const batchCount = useQueueSettingsStore().batchCount
|
||||||
|
|
||||||
if (isCloud) {
|
useTelemetry()?.trackWorkflowExecution()
|
||||||
useTelemetry()?.trackWorkflowExecution()
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.queuePrompt(-1, batchCount)
|
await app.queuePrompt(-1, batchCount)
|
||||||
}
|
}
|
||||||
@@ -720,6 +730,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
menubarLabel: 'ComfyUI Issues',
|
menubarLabel: 'ComfyUI Issues',
|
||||||
versionAdded: '1.5.5',
|
versionAdded: '1.5.5',
|
||||||
function: () => {
|
function: () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'github',
|
||||||
|
is_external: true,
|
||||||
|
source: 'menu'
|
||||||
|
})
|
||||||
window.open(
|
window.open(
|
||||||
'https://github.com/comfyanonymous/ComfyUI/issues',
|
'https://github.com/comfyanonymous/ComfyUI/issues',
|
||||||
'_blank'
|
'_blank'
|
||||||
@@ -733,6 +748,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
menubarLabel: 'ComfyUI Docs',
|
menubarLabel: 'ComfyUI Docs',
|
||||||
versionAdded: '1.5.5',
|
versionAdded: '1.5.5',
|
||||||
function: () => {
|
function: () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'docs',
|
||||||
|
is_external: true,
|
||||||
|
source: 'menu'
|
||||||
|
})
|
||||||
window.open('https://docs.comfy.org/', '_blank')
|
window.open('https://docs.comfy.org/', '_blank')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -743,6 +763,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
menubarLabel: 'Comfy-Org Discord',
|
menubarLabel: 'Comfy-Org Discord',
|
||||||
versionAdded: '1.5.5',
|
versionAdded: '1.5.5',
|
||||||
function: () => {
|
function: () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'discord',
|
||||||
|
is_external: true,
|
||||||
|
source: 'menu'
|
||||||
|
})
|
||||||
window.open('https://www.comfy.org/discord', '_blank')
|
window.open('https://www.comfy.org/discord', '_blank')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -800,6 +825,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
menubarLabel: 'ComfyUI Forum',
|
menubarLabel: 'ComfyUI Forum',
|
||||||
versionAdded: '1.8.2',
|
versionAdded: '1.8.2',
|
||||||
function: () => {
|
function: () => {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'help_feedback',
|
||||||
|
is_external: true,
|
||||||
|
source: 'menu'
|
||||||
|
})
|
||||||
window.open('https://forum.comfy.org/', '_blank')
|
window.open('https://forum.comfy.org/', '_blank')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { refDebounced } from '@vueuse/core'
|
import { refDebounced } from '@vueuse/core'
|
||||||
|
import { debounce } from 'es-toolkit/compat'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||||
|
|
||||||
export function useTemplateFiltering(
|
export function useTemplateFiltering(
|
||||||
@@ -189,6 +191,38 @@ export function useTemplateFiltering(
|
|||||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||||
const totalCount = computed(() => templatesArray.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 {
|
return {
|
||||||
// State
|
// State
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isPolling = ref(false)
|
const isPolling = ref(false)
|
||||||
@@ -77,6 +78,7 @@ const startPollingSubscriptionStatus = () => {
|
|||||||
|
|
||||||
if (isActiveSubscription.value) {
|
if (isActiveSubscription.value) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
telemetry?.trackMonthlySubscriptionSucceeded()
|
||||||
emit('subscribed')
|
emit('subscribed')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export function useSubscriptionActions() {
|
|||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
|
const telemetry = useTelemetry()
|
||||||
const { fetchStatus, formattedRenewalDate } = useSubscription()
|
const { fetchStatus, formattedRenewalDate } = useSubscription()
|
||||||
|
|
||||||
const isLoadingSupport = ref(false)
|
const isLoadingSupport = ref(false)
|
||||||
@@ -35,6 +38,13 @@ export function useSubscriptionActions() {
|
|||||||
const handleMessageSupport = async () => {
|
const handleMessageSupport = async () => {
|
||||||
try {
|
try {
|
||||||
isLoadingSupport.value = true
|
isLoadingSupport.value = true
|
||||||
|
if (isCloud) {
|
||||||
|
telemetry?.trackHelpResourceClicked({
|
||||||
|
resource_type: 'help_feedback',
|
||||||
|
is_external: true,
|
||||||
|
source: 'subscription'
|
||||||
|
})
|
||||||
|
}
|
||||||
await commandStore.execute('Comfy.ContactSupport')
|
await commandStore.execute('Comfy.ContactSupport')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useSubscriptionActions] Error contacting support:', error)
|
console.error('[useSubscriptionActions] Error contacting support:', error)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||||
|
|
||||||
import { app } from '@/scripts/app'
|
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
import { computeNodeMetrics } from '@/platform/telemetry/utils/computeNodeMetrics'
|
||||||
import { NodeSourceType } from '@/types/nodeSource'
|
import type { NodeMetrics } from '@/platform/telemetry/utils/computeNodeMetrics'
|
||||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AuthMetadata,
|
AuthMetadata,
|
||||||
@@ -11,6 +11,9 @@ import type {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
ExecutionErrorMetadata,
|
ExecutionErrorMetadata,
|
||||||
ExecutionSuccessMetadata,
|
ExecutionSuccessMetadata,
|
||||||
|
HelpCenterClosedMetadata,
|
||||||
|
HelpCenterOpenedMetadata,
|
||||||
|
HelpResourceClickedMetadata,
|
||||||
NodeSearchMetadata,
|
NodeSearchMetadata,
|
||||||
NodeSearchResultMetadata,
|
NodeSearchResultMetadata,
|
||||||
PageVisibilityMetadata,
|
PageVisibilityMetadata,
|
||||||
@@ -24,6 +27,7 @@ import type {
|
|||||||
TemplateLibraryClosedMetadata,
|
TemplateLibraryClosedMetadata,
|
||||||
TemplateLibraryMetadata,
|
TemplateLibraryMetadata,
|
||||||
TemplateMetadata,
|
TemplateMetadata,
|
||||||
|
WorkflowCreatedMetadata,
|
||||||
WorkflowImportMetadata
|
WorkflowImportMetadata
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { TelemetryEvents } from '../../types'
|
import { TelemetryEvents } from '../../types'
|
||||||
@@ -61,6 +65,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
private _settingStore: any = null
|
private _settingStore: any = null
|
||||||
private _composablesReady = false
|
private _composablesReady = false
|
||||||
|
|
||||||
|
// Injected dependencies to avoid circular dependency with app.ts
|
||||||
|
private _graph: LGraph | null = null
|
||||||
|
private _nodeDefsByName: Record<string, ComfyNodeDefImpl> = {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const token = window.__CONFIG__?.mixpanel_token
|
const token = window.__CONFIG__?.mixpanel_token
|
||||||
|
|
||||||
@@ -147,6 +155,20 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
void this.initializeComposables()
|
void this.initializeComposables()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the graph context for node metrics computation.
|
||||||
|
* Must be called after app.graph is initialized to enable full telemetry context.
|
||||||
|
* @param graph - The LiteGraph instance
|
||||||
|
* @param nodeDefsByName - Map of node type names to their definitions
|
||||||
|
*/
|
||||||
|
setGraphContext(
|
||||||
|
graph: LGraph,
|
||||||
|
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||||
|
): void {
|
||||||
|
this._graph = graph
|
||||||
|
this._nodeDefsByName = nodeDefsByName
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy initialization of Vue composables to avoid circular dependencies during module loading.
|
* Lazy initialization of Vue composables to avoid circular dependencies during module loading.
|
||||||
* Only imports and initializes composables once when app is ready.
|
* Only imports and initializes composables once when app is ready.
|
||||||
@@ -279,6 +301,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackUserLoggedIn(): void {
|
||||||
|
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
|
||||||
|
}
|
||||||
|
|
||||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||||
const eventName =
|
const eventName =
|
||||||
event === 'modal_opened'
|
event === 'modal_opened'
|
||||||
@@ -437,6 +463,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||||
|
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||||
|
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||||
|
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||||
|
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
trackWorkflowExecution(): void {
|
trackWorkflowExecution(): void {
|
||||||
if (this.isOnboardingMode) {
|
if (this.isOnboardingMode) {
|
||||||
// During onboarding, track basic execution without workflow context
|
// During onboarding, track basic execution without workflow context
|
||||||
@@ -471,60 +513,17 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
|||||||
void this.initializeComposables()
|
void this.initializeComposables()
|
||||||
}
|
}
|
||||||
|
|
||||||
let nodeCounts: {
|
// Return zero node counts if graph context not injected yet
|
||||||
custom_node_count: number
|
const nodeCounts: NodeMetrics = this._graph
|
||||||
api_node_count: number
|
? computeNodeMetrics(this._graph, this._nodeDefsByName)
|
||||||
subgraph_count: number
|
: {
|
||||||
total_node_count: number
|
custom_node_count: 0,
|
||||||
has_api_nodes: boolean
|
api_node_count: 0,
|
||||||
api_node_names: string[]
|
subgraph_count: 0,
|
||||||
}
|
total_node_count: 0,
|
||||||
try {
|
has_api_nodes: false,
|
||||||
const nodeDefStore = useNodeDefStore()
|
api_node_names: []
|
||||||
const nodes = collectAllNodes(app.graph)
|
|
||||||
|
|
||||||
let customNodeCount = 0
|
|
||||||
let apiNodeCount = 0
|
|
||||||
let subgraphCount = 0
|
|
||||||
let totalNodeCount = 0
|
|
||||||
let hasApiNodes = false
|
|
||||||
const apiNodeNames = new Set<string>()
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
totalNodeCount += 1
|
|
||||||
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
|
|
||||||
const isCustomNode =
|
|
||||||
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
|
|
||||||
const isApiNode = nodeDef?.api_node === true
|
|
||||||
const isSubgraph = node.isSubgraphNode?.() === true
|
|
||||||
if (isCustomNode) customNodeCount += 1
|
|
||||||
if (isApiNode) {
|
|
||||||
apiNodeCount += 1
|
|
||||||
hasApiNodes = true
|
|
||||||
if (nodeDef?.name) apiNodeNames.add(nodeDef.name)
|
|
||||||
}
|
}
|
||||||
if (isSubgraph) subgraphCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeCounts = {
|
|
||||||
custom_node_count: customNodeCount,
|
|
||||||
api_node_count: apiNodeCount,
|
|
||||||
subgraph_count: subgraphCount,
|
|
||||||
total_node_count: totalNodeCount,
|
|
||||||
has_api_nodes: hasApiNodes,
|
|
||||||
api_node_names: Array.from(apiNodeNames)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to compute node metrics:', error)
|
|
||||||
nodeCounts = {
|
|
||||||
custom_node_count: 0,
|
|
||||||
api_node_count: 0,
|
|
||||||
subgraph_count: 0,
|
|
||||||
total_node_count: 0,
|
|
||||||
has_api_nodes: false,
|
|
||||||
api_node_names: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this._composablesReady ||
|
!this._composablesReady ||
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||||
* 3. Check dist/assets/*.js files contain no tracking code
|
* 3. Check dist/assets/*.js files contain no tracking code
|
||||||
*/
|
*/
|
||||||
|
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication metadata for sign-up tracking
|
* Authentication metadata for sign-up tracking
|
||||||
@@ -26,7 +28,6 @@ export interface AuthMetadata {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Survey response data for user profiling
|
* Survey response data for user profiling
|
||||||
* Maps 1-to-1 with actual survey fields
|
|
||||||
*/
|
*/
|
||||||
export interface SurveyResponses {
|
export interface SurveyResponses {
|
||||||
familiarity?: string
|
familiarity?: string
|
||||||
@@ -192,6 +193,48 @@ export interface TemplateFilterMetadata {
|
|||||||
total_count: number
|
total_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help center opened metadata
|
||||||
|
*/
|
||||||
|
export interface HelpCenterOpenedMetadata {
|
||||||
|
source: 'menu' | 'topbar' | 'sidebar'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help resource clicked metadata
|
||||||
|
*/
|
||||||
|
export interface HelpResourceClickedMetadata {
|
||||||
|
resource_type:
|
||||||
|
| 'docs'
|
||||||
|
| 'discord'
|
||||||
|
| 'github'
|
||||||
|
| 'help_feedback'
|
||||||
|
| 'manager'
|
||||||
|
| 'release_notes'
|
||||||
|
is_external: boolean
|
||||||
|
source:
|
||||||
|
| 'menu'
|
||||||
|
| 'help_center'
|
||||||
|
| 'error_dialog'
|
||||||
|
| 'credits_panel'
|
||||||
|
| 'subscription'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help center closed metadata
|
||||||
|
*/
|
||||||
|
export interface HelpCenterClosedMetadata {
|
||||||
|
time_spent_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow created metadata
|
||||||
|
*/
|
||||||
|
export interface WorkflowCreatedMetadata {
|
||||||
|
workflow_type: 'blank' | 'default'
|
||||||
|
previous_workflow_had_nodes: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core telemetry provider interface
|
* Core telemetry provider interface
|
||||||
*/
|
*/
|
||||||
@@ -199,6 +242,7 @@ export interface TelemetryProvider {
|
|||||||
// Authentication flow events
|
// Authentication flow events
|
||||||
trackSignupOpened(): void
|
trackSignupOpened(): void
|
||||||
trackAuth(metadata: AuthMetadata): void
|
trackAuth(metadata: AuthMetadata): void
|
||||||
|
trackUserLoggedIn(): void
|
||||||
|
|
||||||
// Subscription flow events
|
// Subscription flow events
|
||||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||||
@@ -234,6 +278,14 @@ export interface TelemetryProvider {
|
|||||||
// Template filter tracking events
|
// Template filter tracking events
|
||||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
|
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
|
||||||
|
|
||||||
|
// Help center events
|
||||||
|
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
|
||||||
|
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
|
||||||
|
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
|
||||||
|
|
||||||
|
// Workflow creation events
|
||||||
|
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
|
||||||
|
|
||||||
// Workflow execution events
|
// Workflow execution events
|
||||||
trackWorkflowExecution(): void
|
trackWorkflowExecution(): void
|
||||||
trackExecutionError(metadata: ExecutionErrorMetadata): void
|
trackExecutionError(metadata: ExecutionErrorMetadata): void
|
||||||
@@ -241,6 +293,10 @@ export interface TelemetryProvider {
|
|||||||
|
|
||||||
// App lifecycle management
|
// App lifecycle management
|
||||||
markAppReady?(): void
|
markAppReady?(): void
|
||||||
|
setGraphContext?(
|
||||||
|
graph: LGraph,
|
||||||
|
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||||
|
): void
|
||||||
identifyUser?(userId: string): void
|
identifyUser?(userId: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +311,7 @@ export const TelemetryEvents = {
|
|||||||
// Authentication Flow
|
// Authentication Flow
|
||||||
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
|
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
|
||||||
USER_AUTH_COMPLETED: 'app:user_auth_completed',
|
USER_AUTH_COMPLETED: 'app:user_auth_completed',
|
||||||
|
USER_LOGGED_IN: 'app:user_logged_in',
|
||||||
|
|
||||||
// Subscription Flow
|
// Subscription Flow
|
||||||
RUN_BUTTON_CLICKED: 'app:run_button_click',
|
RUN_BUTTON_CLICKED: 'app:run_button_click',
|
||||||
@@ -293,6 +350,14 @@ export const TelemetryEvents = {
|
|||||||
// Template Filter Analytics
|
// Template Filter Analytics
|
||||||
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
|
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
|
||||||
|
|
||||||
|
// Help Center Analytics
|
||||||
|
HELP_CENTER_OPENED: 'app:help_center_opened',
|
||||||
|
HELP_RESOURCE_CLICKED: 'app:help_resource_clicked',
|
||||||
|
HELP_CENTER_CLOSED: 'app:help_center_closed',
|
||||||
|
|
||||||
|
// Workflow Creation
|
||||||
|
WORKFLOW_CREATED: 'app:workflow_created',
|
||||||
|
|
||||||
// Execution Lifecycle
|
// Execution Lifecycle
|
||||||
EXECUTION_START: 'execution_start',
|
EXECUTION_START: 'execution_start',
|
||||||
EXECUTION_ERROR: 'execution_error',
|
EXECUTION_ERROR: 'execution_error',
|
||||||
@@ -322,3 +387,7 @@ export type TelemetryEventProperties =
|
|||||||
| NodeSearchMetadata
|
| NodeSearchMetadata
|
||||||
| NodeSearchResultMetadata
|
| NodeSearchResultMetadata
|
||||||
| TemplateFilterMetadata
|
| TemplateFilterMetadata
|
||||||
|
| HelpCenterOpenedMetadata
|
||||||
|
| HelpResourceClickedMetadata
|
||||||
|
| HelpCenterClosedMetadata
|
||||||
|
| WorkflowCreatedMetadata
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
normalizeSurveyResponses,
|
||||||
|
normalizeUseCase
|
||||||
|
} 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
84
src/platform/telemetry/utils/computeNodeMetrics.ts
Normal file
84
src/platform/telemetry/utils/computeNodeMetrics.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||||
|
import { NodeSourceType } from '@/types/nodeSource'
|
||||||
|
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||||
|
|
||||||
|
export interface NodeMetrics {
|
||||||
|
custom_node_count: number
|
||||||
|
api_node_count: number
|
||||||
|
subgraph_count: number
|
||||||
|
total_node_count: number
|
||||||
|
has_api_nodes: boolean
|
||||||
|
api_node_names: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes node composition metrics for a given graph.
|
||||||
|
*
|
||||||
|
* Analyzes the graph to determine:
|
||||||
|
* - Total node count
|
||||||
|
* - Custom node count (from extensions)
|
||||||
|
* - API node count (ComfyUI API nodes)
|
||||||
|
* - Subgraph count (nested workflows)
|
||||||
|
* - API node names
|
||||||
|
*
|
||||||
|
* @param graph - The LiteGraph instance to analyze
|
||||||
|
* @param nodeDefsByName - Map of node type to node definition
|
||||||
|
* @returns Node metrics including counts and API node information
|
||||||
|
*/
|
||||||
|
export function computeNodeMetrics(
|
||||||
|
graph: LGraph | null,
|
||||||
|
nodeDefsByName: Record<string, ComfyNodeDefImpl>
|
||||||
|
): NodeMetrics {
|
||||||
|
const defaultMetrics: NodeMetrics = {
|
||||||
|
custom_node_count: 0,
|
||||||
|
api_node_count: 0,
|
||||||
|
subgraph_count: 0,
|
||||||
|
total_node_count: 0,
|
||||||
|
has_api_nodes: false,
|
||||||
|
api_node_names: []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!graph) {
|
||||||
|
return defaultMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nodes = collectAllNodes(graph)
|
||||||
|
|
||||||
|
let customNodeCount = 0
|
||||||
|
let apiNodeCount = 0
|
||||||
|
let subgraphCount = 0
|
||||||
|
let totalNodeCount = 0
|
||||||
|
let hasApiNodes = false
|
||||||
|
const apiNodeNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
totalNodeCount += 1
|
||||||
|
const nodeDef = nodeDefsByName[node.type ?? '']
|
||||||
|
const isCustomNode =
|
||||||
|
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
|
||||||
|
const isApiNode = nodeDef?.api_node === true
|
||||||
|
const isSubgraph = node.isSubgraphNode?.() === true
|
||||||
|
if (isCustomNode) customNodeCount += 1
|
||||||
|
if (isApiNode) {
|
||||||
|
apiNodeCount += 1
|
||||||
|
hasApiNodes = true
|
||||||
|
if (nodeDef?.name) apiNodeNames.add(nodeDef.name)
|
||||||
|
}
|
||||||
|
if (isSubgraph) subgraphCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
custom_node_count: customNodeCount,
|
||||||
|
api_node_count: apiNodeCount,
|
||||||
|
subgraph_count: subgraphCount,
|
||||||
|
total_node_count: totalNodeCount,
|
||||||
|
has_api_nodes: hasApiNodes,
|
||||||
|
api_node_names: Array.from(apiNodeNames)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to compute node metrics:', error)
|
||||||
|
return defaultMetrics
|
||||||
|
}
|
||||||
|
}
|
||||||
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
606
src/platform/telemetry/utils/surveyNormalization.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* Survey Response Normalization Utilities
|
||||||
|
*
|
||||||
|
* Smart categorization system to normalize free-text survey responses
|
||||||
|
* into standardized categories for better analytics breakdowns.
|
||||||
|
* Uses Fuse.js for fuzzy matching against category keywords.
|
||||||
|
*/
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
|
||||||
|
interface CategoryMapping {
|
||||||
|
name: string
|
||||||
|
keywords: string[]
|
||||||
|
userCount?: number // For reference from analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Industry category mappings based on ~9,000 user analysis
|
||||||
|
*/
|
||||||
|
const INDUSTRY_CATEGORIES: CategoryMapping[] = [
|
||||||
|
{
|
||||||
|
name: 'Film / TV / Animation',
|
||||||
|
userCount: 2885,
|
||||||
|
keywords: [
|
||||||
|
'film',
|
||||||
|
'tv',
|
||||||
|
'television',
|
||||||
|
'animation',
|
||||||
|
'animation studio',
|
||||||
|
'tv production',
|
||||||
|
'film production',
|
||||||
|
'story',
|
||||||
|
'anime',
|
||||||
|
'video',
|
||||||
|
'cinematography',
|
||||||
|
'visual effects',
|
||||||
|
'vfx',
|
||||||
|
'vfx artist',
|
||||||
|
'movie',
|
||||||
|
'cinema',
|
||||||
|
'documentary',
|
||||||
|
'documentary filmmaker',
|
||||||
|
'broadcast',
|
||||||
|
'streaming',
|
||||||
|
'production',
|
||||||
|
'director',
|
||||||
|
'filmmaker',
|
||||||
|
'post-production',
|
||||||
|
'editing'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marketing / Advertising / Social Media',
|
||||||
|
userCount: 1340,
|
||||||
|
keywords: [
|
||||||
|
'marketing',
|
||||||
|
'advertising',
|
||||||
|
'youtube',
|
||||||
|
'tiktok',
|
||||||
|
'social media',
|
||||||
|
'content creation',
|
||||||
|
'influencer',
|
||||||
|
'brand',
|
||||||
|
'promotion',
|
||||||
|
'digital marketing',
|
||||||
|
'seo',
|
||||||
|
'campaigns',
|
||||||
|
'copywriting',
|
||||||
|
'growth',
|
||||||
|
'engagement'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Software / IT / AI',
|
||||||
|
userCount: 1100,
|
||||||
|
keywords: [
|
||||||
|
'software',
|
||||||
|
'software development',
|
||||||
|
'software engineer',
|
||||||
|
'it',
|
||||||
|
'ai',
|
||||||
|
'ai research',
|
||||||
|
'corporate ai research',
|
||||||
|
'ai research lab',
|
||||||
|
'tech company ai research',
|
||||||
|
'developer',
|
||||||
|
'app developer',
|
||||||
|
'consulting',
|
||||||
|
'tech',
|
||||||
|
'tech startup',
|
||||||
|
'programmer',
|
||||||
|
'data science',
|
||||||
|
'machine learning',
|
||||||
|
'coding',
|
||||||
|
'programming',
|
||||||
|
'web development',
|
||||||
|
'app development',
|
||||||
|
'saas'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Product & Industrial Design',
|
||||||
|
userCount: 1050,
|
||||||
|
keywords: [
|
||||||
|
'product design',
|
||||||
|
'industrial',
|
||||||
|
'manufacturing',
|
||||||
|
'3d rendering',
|
||||||
|
'product visualization',
|
||||||
|
'mechanical',
|
||||||
|
'automotive',
|
||||||
|
'cad',
|
||||||
|
'prototype',
|
||||||
|
'design engineering',
|
||||||
|
'invention'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fine Art / Contemporary Art',
|
||||||
|
userCount: 780,
|
||||||
|
keywords: [
|
||||||
|
'fine art',
|
||||||
|
'art',
|
||||||
|
'illustration',
|
||||||
|
'contemporary',
|
||||||
|
'artist',
|
||||||
|
'painting',
|
||||||
|
'drawing',
|
||||||
|
'sculpture',
|
||||||
|
'gallery',
|
||||||
|
'canvas',
|
||||||
|
'digital art',
|
||||||
|
'mixed media',
|
||||||
|
'abstract',
|
||||||
|
'portrait'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Education / Research',
|
||||||
|
userCount: 640,
|
||||||
|
keywords: [
|
||||||
|
'education',
|
||||||
|
'student',
|
||||||
|
'teacher',
|
||||||
|
'research',
|
||||||
|
'university research',
|
||||||
|
'academic ai research',
|
||||||
|
'university ai research',
|
||||||
|
'ai research at university',
|
||||||
|
'learning',
|
||||||
|
'university',
|
||||||
|
'school',
|
||||||
|
'academic',
|
||||||
|
'professor',
|
||||||
|
'curriculum',
|
||||||
|
'training',
|
||||||
|
'instruction',
|
||||||
|
'pedagogy'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Architecture / Engineering / Construction',
|
||||||
|
userCount: 420,
|
||||||
|
keywords: [
|
||||||
|
'architecture',
|
||||||
|
'architecture firm',
|
||||||
|
'construction',
|
||||||
|
'engineering',
|
||||||
|
'civil',
|
||||||
|
'civil engineering',
|
||||||
|
'cad',
|
||||||
|
'building',
|
||||||
|
'structural',
|
||||||
|
'landscape',
|
||||||
|
'landscape architecture',
|
||||||
|
'interior design',
|
||||||
|
'real estate',
|
||||||
|
'planning',
|
||||||
|
'blueprints'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gaming / Interactive Media',
|
||||||
|
userCount: 410,
|
||||||
|
keywords: [
|
||||||
|
'gaming',
|
||||||
|
'game dev',
|
||||||
|
'game development',
|
||||||
|
'indie game studio',
|
||||||
|
'vr development',
|
||||||
|
'roblox',
|
||||||
|
'interactive',
|
||||||
|
'interactive media',
|
||||||
|
'virtual world',
|
||||||
|
'vr',
|
||||||
|
'ar',
|
||||||
|
'metaverse',
|
||||||
|
'simulation',
|
||||||
|
'unity',
|
||||||
|
'unity developer',
|
||||||
|
'unreal',
|
||||||
|
'indie games'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Photography / Videography',
|
||||||
|
userCount: 70,
|
||||||
|
keywords: [
|
||||||
|
'photography',
|
||||||
|
'photo',
|
||||||
|
'videography',
|
||||||
|
'camera',
|
||||||
|
'image',
|
||||||
|
'portrait',
|
||||||
|
'wedding',
|
||||||
|
'commercial photo',
|
||||||
|
'stock photography',
|
||||||
|
'photojournalism',
|
||||||
|
'event photography'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fashion / Beauty / Retail',
|
||||||
|
userCount: 25,
|
||||||
|
keywords: [
|
||||||
|
'fashion',
|
||||||
|
'fashion design',
|
||||||
|
'beauty',
|
||||||
|
'beauty industry',
|
||||||
|
'jewelry',
|
||||||
|
'jewelry design',
|
||||||
|
'custom jewelry design',
|
||||||
|
'retail',
|
||||||
|
'retail store',
|
||||||
|
'style',
|
||||||
|
'clothing',
|
||||||
|
'cosmetics',
|
||||||
|
'makeup',
|
||||||
|
'accessories',
|
||||||
|
'boutique'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Music / Performing Arts',
|
||||||
|
userCount: 25,
|
||||||
|
keywords: [
|
||||||
|
'music',
|
||||||
|
'music production',
|
||||||
|
'vj',
|
||||||
|
'dance',
|
||||||
|
'projection mapping',
|
||||||
|
'audio visual',
|
||||||
|
'concert',
|
||||||
|
'concert production',
|
||||||
|
'performance',
|
||||||
|
'theater',
|
||||||
|
'stage',
|
||||||
|
'live events'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Healthcare / Medical / Life Science',
|
||||||
|
userCount: 30,
|
||||||
|
keywords: [
|
||||||
|
'healthcare',
|
||||||
|
'medical',
|
||||||
|
'medical research',
|
||||||
|
'doctor',
|
||||||
|
'biotech',
|
||||||
|
'life science',
|
||||||
|
'pharmaceutical',
|
||||||
|
'clinical',
|
||||||
|
'clinical research',
|
||||||
|
'hospital',
|
||||||
|
'medicine',
|
||||||
|
'health'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E-commerce / Print-on-Demand / Business',
|
||||||
|
userCount: 15,
|
||||||
|
keywords: [
|
||||||
|
'ecommerce',
|
||||||
|
'e-commerce',
|
||||||
|
'print on demand',
|
||||||
|
'shop',
|
||||||
|
'business',
|
||||||
|
'commercial',
|
||||||
|
'startup',
|
||||||
|
'entrepreneur',
|
||||||
|
'sales',
|
||||||
|
'online store'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Nonprofit / Government / Public Sector',
|
||||||
|
userCount: 15,
|
||||||
|
keywords: [
|
||||||
|
'501c3',
|
||||||
|
'ngo',
|
||||||
|
'government',
|
||||||
|
'public service',
|
||||||
|
'policy',
|
||||||
|
'nonprofit',
|
||||||
|
'charity',
|
||||||
|
'civic',
|
||||||
|
'community',
|
||||||
|
'social impact'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Adult / NSFW',
|
||||||
|
userCount: 10,
|
||||||
|
keywords: [
|
||||||
|
'nsfw',
|
||||||
|
'nsfw content',
|
||||||
|
'adult',
|
||||||
|
'adult entertainment',
|
||||||
|
'erotic',
|
||||||
|
'explicit',
|
||||||
|
'xxx',
|
||||||
|
'porn'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use case category mappings based on common patterns
|
||||||
|
*/
|
||||||
|
const USE_CASE_CATEGORIES: CategoryMapping[] = [
|
||||||
|
{
|
||||||
|
name: 'Content Creation & Marketing',
|
||||||
|
keywords: [
|
||||||
|
'content creation',
|
||||||
|
'social media',
|
||||||
|
'marketing',
|
||||||
|
'marketing campaigns',
|
||||||
|
'advertising',
|
||||||
|
'youtube',
|
||||||
|
'youtube thumbnail',
|
||||||
|
'youtube thumbnail generation',
|
||||||
|
'tiktok',
|
||||||
|
'instagram',
|
||||||
|
'thumbnails',
|
||||||
|
'posts',
|
||||||
|
'campaigns',
|
||||||
|
'brand content'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Art & Illustration',
|
||||||
|
keywords: [
|
||||||
|
'art',
|
||||||
|
'illustration',
|
||||||
|
'drawing',
|
||||||
|
'painting',
|
||||||
|
'concept art',
|
||||||
|
'creating concept art',
|
||||||
|
'character design',
|
||||||
|
'digital art',
|
||||||
|
'fantasy art',
|
||||||
|
'portraits'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Product Visualization & Design',
|
||||||
|
keywords: [
|
||||||
|
'product',
|
||||||
|
'product mockup',
|
||||||
|
'product mockup creation',
|
||||||
|
'visualization',
|
||||||
|
'prototype visualization',
|
||||||
|
'design',
|
||||||
|
'prototype',
|
||||||
|
'mockup',
|
||||||
|
'3d rendering',
|
||||||
|
'industrial design',
|
||||||
|
'product photos'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Film & Video Production',
|
||||||
|
keywords: [
|
||||||
|
'film',
|
||||||
|
'video',
|
||||||
|
'video editing',
|
||||||
|
'movie',
|
||||||
|
'movie production',
|
||||||
|
'animation',
|
||||||
|
'vfx',
|
||||||
|
'visual effects',
|
||||||
|
'storyboard',
|
||||||
|
'storyboard creation',
|
||||||
|
'cinematography',
|
||||||
|
'post production'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gaming & Interactive Media',
|
||||||
|
keywords: [
|
||||||
|
'game',
|
||||||
|
'gaming',
|
||||||
|
'game asset generation',
|
||||||
|
'game assets',
|
||||||
|
'game development',
|
||||||
|
'game textures',
|
||||||
|
'interactive',
|
||||||
|
'vr',
|
||||||
|
'vr content creation',
|
||||||
|
'ar',
|
||||||
|
'virtual',
|
||||||
|
'simulation',
|
||||||
|
'metaverse',
|
||||||
|
'textures'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Architecture & Construction',
|
||||||
|
keywords: [
|
||||||
|
'architecture',
|
||||||
|
'architectural rendering',
|
||||||
|
'building',
|
||||||
|
'building visualization',
|
||||||
|
'construction',
|
||||||
|
'interior design',
|
||||||
|
'interior design mockups',
|
||||||
|
'landscape',
|
||||||
|
'real estate',
|
||||||
|
'real estate visualization',
|
||||||
|
'floor plans',
|
||||||
|
'renderings'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Education & Training',
|
||||||
|
keywords: [
|
||||||
|
'education',
|
||||||
|
'educational',
|
||||||
|
'educational content',
|
||||||
|
'training',
|
||||||
|
'training materials',
|
||||||
|
'learning',
|
||||||
|
'teaching',
|
||||||
|
'tutorial',
|
||||||
|
'tutorial creation',
|
||||||
|
'course',
|
||||||
|
'academic',
|
||||||
|
'academic projects',
|
||||||
|
'instructional',
|
||||||
|
'workshops'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Research & Development',
|
||||||
|
keywords: [
|
||||||
|
'research',
|
||||||
|
'research experiments',
|
||||||
|
'development',
|
||||||
|
'experiment',
|
||||||
|
'prototype',
|
||||||
|
'prototype testing',
|
||||||
|
'testing',
|
||||||
|
'analysis',
|
||||||
|
'study',
|
||||||
|
'innovation',
|
||||||
|
'innovation projects',
|
||||||
|
'r&d',
|
||||||
|
'scientific visualization'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Personal & Hobby',
|
||||||
|
keywords: [
|
||||||
|
'personal',
|
||||||
|
'personal art projects',
|
||||||
|
'hobby',
|
||||||
|
'hobby work',
|
||||||
|
'fun',
|
||||||
|
'fun experiments',
|
||||||
|
'experiment',
|
||||||
|
'learning',
|
||||||
|
'curiosity',
|
||||||
|
'explore',
|
||||||
|
'creative',
|
||||||
|
'creative exploration',
|
||||||
|
'side project'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Photography & Image Processing',
|
||||||
|
keywords: [
|
||||||
|
'photography',
|
||||||
|
'product photography',
|
||||||
|
'portrait photography',
|
||||||
|
'photo',
|
||||||
|
'photo editing',
|
||||||
|
'image',
|
||||||
|
'image enhancement',
|
||||||
|
'portrait',
|
||||||
|
'editing',
|
||||||
|
'enhancement',
|
||||||
|
'restoration',
|
||||||
|
'photo manipulation'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuse.js configuration for category matching
|
||||||
|
*/
|
||||||
|
const FUSE_OPTIONS = {
|
||||||
|
keys: ['keywords'],
|
||||||
|
threshold: 0.53, // Higher = more lenient matching
|
||||||
|
minMatchCharLength: 5,
|
||||||
|
includeScore: true,
|
||||||
|
includeMatches: true,
|
||||||
|
ignoreLocation: true,
|
||||||
|
findAllMatches: true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Fuse instances for category matching
|
||||||
|
*/
|
||||||
|
const industryFuse = new Fuse(INDUSTRY_CATEGORIES, FUSE_OPTIONS)
|
||||||
|
const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize industry responses using Fuse.js fuzzy search
|
||||||
|
*/
|
||||||
|
export function normalizeIndustry(rawIndustry: string): string {
|
||||||
|
if (!rawIndustry || typeof rawIndustry !== 'string') {
|
||||||
|
return 'Other / Undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
const industry = rawIndustry.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Handle common undefined responses
|
||||||
|
if (
|
||||||
|
industry.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
|
||||||
|
) {
|
||||||
|
return 'Other / Undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuse.js fuzzy search for best category match
|
||||||
|
const results = industryFuse.search(rawIndustry)
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
return results[0].item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// No good match found - preserve original with prefix
|
||||||
|
return `Uncategorized: ${rawIndustry}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize use case responses using Fuse.js fuzzy search
|
||||||
|
*/
|
||||||
|
export function normalizeUseCase(rawUseCase: string): string {
|
||||||
|
if (!rawUseCase || typeof rawUseCase !== 'string') {
|
||||||
|
return 'Other / Undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCase = rawUseCase.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Handle common undefined responses
|
||||||
|
if (
|
||||||
|
useCase.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
|
||||||
|
) {
|
||||||
|
return 'Other / Undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuse.js fuzzy search for best category match
|
||||||
|
const results = useCaseFuse.search(rawUseCase)
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
return results[0].item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// No good match found - preserve original with prefix
|
||||||
|
return `Uncategorized: ${rawUseCase}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply normalization to survey responses
|
||||||
|
* Creates both normalized and raw versions of responses
|
||||||
|
*/
|
||||||
|
export function normalizeSurveyResponses(responses: {
|
||||||
|
industry?: string
|
||||||
|
useCase?: string
|
||||||
|
[key: string]: any
|
||||||
|
}) {
|
||||||
|
const normalized = { ...responses }
|
||||||
|
|
||||||
|
// Normalize industry
|
||||||
|
if (responses.industry) {
|
||||||
|
normalized.industry_normalized = normalizeIndustry(responses.industry)
|
||||||
|
normalized.industry_raw = responses.industry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize use case
|
||||||
|
if (responses.useCase) {
|
||||||
|
normalized.useCase_normalized = normalizeUseCase(responses.useCase)
|
||||||
|
normalized.useCase_raw = responses.useCase
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@@ -938,6 +938,10 @@ export class ComfyApp {
|
|||||||
|
|
||||||
await useExtensionService().invokeExtensionsAsync('setup')
|
await useExtensionService().invokeExtensionsAsync('setup')
|
||||||
useTelemetry()?.markAppReady?.()
|
useTelemetry()?.markAppReady?.()
|
||||||
|
useTelemetry()?.setGraphContext?.(
|
||||||
|
this.graph,
|
||||||
|
useNodeDefStore().nodeDefsByName
|
||||||
|
)
|
||||||
|
|
||||||
this.positionConversion = useCanvasPositionConversion(
|
this.positionConversion = useCanvasPositionConversion(
|
||||||
this.canvasContainer,
|
this.canvasContainer,
|
||||||
|
|||||||
@@ -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
|
* Options for collectFromNodes function
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
|||||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||||
import { i18n, loadLocale } from '@/i18n'
|
import { i18n, loadLocale } from '@/i18n'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
|
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
|
||||||
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
|
||||||
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
|
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||||
@@ -56,6 +58,7 @@ import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
|||||||
import { useKeybindingService } from '@/services/keybindingService'
|
import { useKeybindingService } from '@/services/keybindingService'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useExecutionStore } from '@/stores/executionStore'
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||||
import { useModelStore } from '@/stores/modelStore'
|
import { useModelStore } from '@/stores/modelStore'
|
||||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||||
@@ -89,6 +92,13 @@ const showBottomMenu = computed(
|
|||||||
() => !isMobile.value && useNewMenu.value === 'Bottom'
|
() => !isMobile.value && useNewMenu.value === 'Bottom'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
watch(
|
||||||
() => colorPaletteStore.completedActivePalette,
|
() => colorPaletteStore.completedActivePalette,
|
||||||
(newTheme) => {
|
(newTheme) => {
|
||||||
@@ -244,6 +254,22 @@ onBeforeUnmount(() => {
|
|||||||
api.removeEventListener('reconnecting', onReconnecting)
|
api.removeEventListener('reconnecting', onReconnecting)
|
||||||
api.removeEventListener('reconnected', onReconnected)
|
api.removeEventListener('reconnected', onReconnected)
|
||||||
executionStore.unbindExecutionEvents()
|
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)
|
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
|
||||||
@@ -262,6 +288,61 @@ void nextTick(() => {
|
|||||||
|
|
||||||
const onGraphReady = () => {
|
const onGraphReady = () => {
|
||||||
runWhenGlobalIdle(() => {
|
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.
|
// Setting values now available after comfyApp.setup.
|
||||||
// Load keybindings.
|
// Load keybindings.
|
||||||
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
|
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ const mockT = vi.fn((key: string) => {
|
|||||||
return key
|
return key
|
||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
useI18n: () => ({
|
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||||
t: mockT
|
return {
|
||||||
})
|
...actual,
|
||||||
}))
|
useI18n: () => ({
|
||||||
|
t: mockT
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||||
useFirebaseAuthActions: () => ({
|
useFirebaseAuthActions: () => ({
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
createTestSubgraphNode
|
createTestSubgraphNode
|
||||||
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
|
} 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
|
// Add mock for api at the top of the file
|
||||||
vi.mock('@/scripts/api', () => ({
|
vi.mock('@/scripts/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
|
|||||||
Reference in New Issue
Block a user