Compare commits

...

4 Commits

Author SHA1 Message Date
Comfy Org PR Bot
d8aa4382a9 1.45.19 (#12992)
Patch version increment to 1.45.19

**Base branch:** `core/1.45`

Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2026-06-19 14:07:09 -07:00
Benjamin Lu
e2f492b07b [backport core/1.45] Add Desktop telemetry event sink (#12802) (#12994)
## Summary

Backports #12802 on top of the #12925 core/1.45 backport branch so
Desktop host telemetry can register a host sink and forward supported
telemetry events through the Desktop bridge.

## Changes

- **What**: Adds initHostTelemetry and HostTelemetrySink, wires host
telemetry bootstrap through src/main.ts, and lets execution
success/error telemetry flow through any registered provider.
- **Dependencies**: Stacked on #12993 / bl/backport-12925-core-1.45.

## Review Focus

Cherry-pick conflicts were in refreshRemoteConfig and executionStore
execution_success handling. I kept the release-branch remote config
shape and added only the unauthenticated fetch helper needed for host
telemetry bootstrap. I trimmed main-only host sink methods/events and
test fixture fields that do not exist on core/1.45, and kept dock_state
out of this release backport stack.

Validation: pnpm typecheck via amend hook; pnpm test:unit
src/platform/telemetry/initHostTelemetry.test.ts
src/platform/telemetry/providers/host/HostTelemetrySink.test.ts
src/stores/executionStore.test.ts. Pre-push knip passed; it still
reports the existing flac metadata tag hint.
2026-06-19 12:09:45 -07:00
Benjamin Lu
cad2742ad6 [backport core/1.45] Decouple run telemetry context from providers (#12925) (#12993)
## Summary

Backports #12925 on top of the #12857 core/1.45 backport branch so
run-button telemetry context is assembled before providers receive the
event.

## Changes

- **What**: Adds useRunButtonTelemetry, moves AppMode helpers into
src/utils/appMode.ts, and updates cloud telemetry providers to accept
completed RunButtonProperties.
- **Dependencies**: Stacked on #12991 / bl/backport-12857-core-1.45.

## Review Focus

Cherry-pick conflicts were in app-mode helper extraction,
SubscribeToRun, cloud telemetry providers/tests, executionStore imports,
and GraphView shell-layout snapshot code. I resolved toward the #12925
run-button-provider decoupling, but kept the core/1.45 deletion of
getShellLayoutSnapshot instead of pulling the unrelated shell-layout
snapshot path into this backport. I also dropped main-only dock_state
payload wiring to keep the release-branch diff smaller.

Validation: pnpm typecheck via commit hook. Pre-push knip passed; it
still reports the existing flac metadata tag hint.
2026-06-19 09:42:32 -07:00
Benjamin Lu
f718200bb4 [backport core/1.45] Move Comfy Desktop bridge types into frontend (#12857) (#12991)
## Summary

Backports #12857 to core/1.45 so the Desktop bridge types package and
publish workflow are available for the host telemetry stack.

## Changes

- **What**: Adds the @comfyorg/comfyui-desktop-bridge-types workspace
package, publish workflow, generated types dependency handling, and
Desktop bridge remote-mode type usage.
- **Dependencies**: Adds the frontend app dependency on
@comfyorg/comfyui-desktop-bridge-types via workspace:*.

## Review Focus

Cherry-pick conflict was limited to pnpm-workspace.yaml; resolved by
keeping the core/1.45 overrides block unchanged because #12857 only
changed a comment on main for preexisting security overrides.

Validation: pnpm typecheck via commit hook; pnpm test:unit
src/platform/missingModel/missingModelDownload.test.ts.
2026-06-18 22:25:47 -07:00
40 changed files with 1575 additions and 191 deletions

View File

@@ -0,0 +1,142 @@
name: Publish Desktop Bridge Types
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g., 0.1.2)'
required: true
type: string
dist_tag:
description: 'npm dist-tag to use'
required: true
default: latest
type: string
ref:
description: 'Git ref to checkout (commit SHA, tag, or branch)'
required: false
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: latest
ref:
required: false
type: string
secrets:
NPM_TOKEN:
required: true
concurrency:
group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }}
cancel-in-progress: false
jobs:
publish_desktop_bridge_types:
name: Publish @comfyorg/comfyui-desktop-bridge-types
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate inputs
env:
VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2
exit 1
fi
- name: Determine ref to checkout
id: resolve_ref
env:
REF: ${{ inputs.ref }}
DEFAULT_REF: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [ -z "$REF" ]; then
REF="$DEFAULT_REF"
fi
if ! git check-ref-format --allow-onelevel "$REF"; then
echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2
exit 1
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
- name: Verify package
id: pkg
env:
INPUT_VERSION: ${{ inputs.version }}
shell: bash
run: |
set -euo pipefail
PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json
NAME=$(node -p "require('./${PACKAGE_JSON}').name")
VERSION=$(node -p "require('./${PACKAGE_JSON}').version")
if [ "$VERSION" != "$INPUT_VERSION" ]; then
echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2
exit 1
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if version already on npm
id: check_npm
env:
NAME: ${{ steps.pkg.outputs.name }}
VER: ${{ steps.pkg.outputs.version }}
shell: bash
run: |
set -euo pipefail
STATUS=0
OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish."
else
if echo "$OUTPUT" | grep -q "E404"; then
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Registry lookup failed::$OUTPUT" >&2
exit "$STATUS"
fi
fi
- name: Publish package
if: steps.check_npm.outputs.exists == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
DIST_TAG: ${{ inputs.dist_tag }}
run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts
working-directory: packages/comfyui-desktop-bridge-types

View File

@@ -2,11 +2,11 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/composables/useAppMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AppMode } from '@/utils/appMode'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.18",
"version": "1.45.19",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -60,6 +60,7 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-desktop-bridge-types": "workspace:*",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/fbx-exporter-three": "^1.0.1",

View File

@@ -0,0 +1,91 @@
export interface ComfyDownloadProgress {
url: string
filename: string
directory?: string
progress: number
receivedBytes?: number
totalBytes?: number
speedBytesPerSec?: number
etaSeconds?: number
status:
| 'pending'
| 'downloading'
| 'paused'
| 'completed'
| 'error'
| 'cancelled'
error?: string
isImage?: boolean
}
export interface TerminalRestore {
buffer: string[]
size: { cols: number; rows: number }
exited: boolean
}
export interface LogsRestore {
installationId: string
buffer: string[]
}
export interface LogsOutputMsg {
installationId: string
text: string
}
export type ComfyDesktop2TelemetryValue = string | number | boolean | null
export type ComfyDesktop2TelemetryProperties = Record<
string,
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
write(data: string, installationId?: string): Promise<void>
resize(cols: number, rows: number, installationId?: string): Promise<void>
restart(installationId?: string): Promise<TerminalRestore>
openPopout(): Promise<void>
onOutput(callback: (data: string) => void): () => void
onExited(callback: () => void): () => void
}
export interface ComfyDesktop2LogsBridge {
subscribe(installationId?: string): Promise<LogsRestore>
unsubscribe(installationId?: string): Promise<void>
openPopout(): Promise<void>
onOutput(callback: (msg: LogsOutputMsg) => void): () => void
}
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
}
export interface ComfyDesktop2Bridge {
isRemote(): boolean
downloadModel?: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
downloadAsset?: (
url: string,
filename: string,
authToken?: string
) => Promise<boolean>
pauseDownload?: (url: string) => Promise<boolean>
resumeDownload?: (url: string) => Promise<boolean>
cancelDownload?: (url: string) => Promise<boolean>
onDownloadProgress?: (
callback: (data: ComfyDownloadProgress) => void
) => () => void
reportTheme?: (bg: string, text: string) => void
Terminal?: ComfyDesktop2TerminalBridge
Logs?: ComfyDesktop2LogsBridge
Telemetry?: ComfyDesktop2TelemetryBridge
}
export type ComfyDesktop2BridgeImplementation = {
[K in keyof ComfyDesktop2Bridge]-?: NonNullable<ComfyDesktop2Bridge[K]>
}

View File

@@ -0,0 +1 @@
export * from './comfyDesktopBridge.js'

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,27 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
"author": {
"name": "Comfy Org",
"email": "support@comfy.org",
"url": "https://www.comfy.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git"
},
"files": [
"comfyDesktopBridge.d.ts",
"index.d.ts",
"index.js"
],
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",
"publishConfig": {
"access": "public"
}
}

5
pnpm-lock.yaml generated
View File

@@ -415,6 +415,9 @@ importers:
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-desktop-bridge-types':
specifier: workspace:*
version: link:packages/comfyui-desktop-bridge-types
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
@@ -986,6 +989,8 @@ importers:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
packages/comfyui-desktop-bridge-types: {}
packages/design-system:
dependencies:
'@iconify-json/lucide':

View File

@@ -2,6 +2,12 @@ import fs from 'fs'
import path from 'path'
const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
const desktopBridgeTypesPackage = JSON.parse(
fs.readFileSync(
'./packages/comfyui-desktop-bridge-types/package.json',
'utf8'
)
)
// Create the types-only package.json
const typesPackage = {
@@ -16,7 +22,9 @@ const typesPackage = {
homepage: mainPackage.homepage,
description: `TypeScript definitions for ${mainPackage.name}`,
license: mainPackage.license,
dependencies: {},
dependencies: {
'@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version
},
peerDependencies: {
vue: mainPackage.dependencies.vue,
zod: mainPackage.dependencies.zod
@@ -34,5 +42,3 @@ fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(typesPackage, null, 2)
)
console.log('Types package.json have been prepared in the dist directory')

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'

View File

@@ -1,24 +1,14 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
import type { AppMode } from '@/utils/appMode'
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -29,9 +19,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => isAppModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -4,6 +4,7 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
@@ -85,6 +86,7 @@ export function useCoreCommands(): ComfyCommand[] {
const executionStore = useExecutionStore()
const modelStore = useModelStore()
const telemetry = useTelemetry()
const { trackRunButton } = useRunButtonTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
@@ -499,7 +501,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -522,7 +524,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -544,7 +546,7 @@ export function useCoreCommands(): ComfyCommand[] {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false },
telemetry: {
trackRunButton: vi.fn()
},
executionContext: {
is_template: false,
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: []
},
executionContextError: null as Error | null
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: state.mode,
isAppMode: state.isAppMode
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => state.telemetry
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => {
if (state.executionContextError) throw state.executionContextError
return state.executionContext
}
}))
import {
getRunButtonTelemetryProperties,
useRunButtonTelemetry
} from './useRunButtonTelemetry'
describe('useRunButtonTelemetry', () => {
beforeEach(() => {
state.telemetry.trackRunButton.mockClear()
state.mode.value = 'graph'
state.isAppMode.value = false
state.executionContextError = null
})
it('builds run button properties from workspace state', () => {
expect(
getRunButtonTelemetryProperties({
subscribe_to_run: true,
trigger_source: 'button'
})
).toEqual({
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'Desktop workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false
})
})
it('tracks the completed run button payload', () => {
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
subscribe_to_run: false,
trigger_source: 'linear',
workflow_name: 'Desktop workflow'
})
)
})
it('does not throw when run button context collection fails', () => {
const error = new Error('Context unavailable')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
state.executionContextError = error
try {
expect(() =>
useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' })
).not.toThrow()
expect(state.telemetry.trackRunButton).not.toHaveBeenCalled()
expect(consoleError).toHaveBeenCalledExactlyOnceWith(
'[Telemetry] Run button tracking failed',
error
)
} finally {
consoleError.mockRestore()
}
})
})

View File

@@ -0,0 +1,50 @@
import { useAppMode } from '@/composables/useAppMode'
import { useTelemetry } from '@/platform/telemetry'
import type {
ExecutionTriggerSource,
RunButtonProperties
} from '@/platform/telemetry/types'
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
type RunButtonTelemetryOptions = {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}
export function getRunButtonTelemetryProperties(
options?: RunButtonTelemetryOptions
): RunButtonProperties {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
return {
subscribe_to_run: options?.subscribe_to_run ?? false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
}
export function useRunButtonTelemetry() {
function trackRunButton(options?: RunButtonTelemetryOptions): void {
const telemetry = useTelemetry()
if (!telemetry) return
try {
telemetry.trackRunButton(getRunButtonTelemetryProperties(options))
} catch (error) {
console.error('[Telemetry] Run button tracking failed', error)
}
}
return { trackRunButton }
}

View File

@@ -28,21 +28,27 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (isCloud) {
if (requiresRemoteConfigBootstrap) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
}
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
await initTelemetry()
}
if (hasHostTelemetryBridge) {
const { initHostTelemetry } =
await import('@/platform/telemetry/initHostTelemetry')
initHostTelemetry()
}
const ComfyUIPreset = definePreset(Aura, {
semantic: {
// @ts-expect-error fixme ts strict error

View File

@@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
@@ -36,10 +36,11 @@ const buttonLabel = computed(() =>
)
const { showSubscriptionDialog } = useBillingContext()
const { trackRunButton } = useRunButtonTelemetry()
const handleSubscribeToRun = () => {
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog()

View File

@@ -39,7 +39,6 @@ beforeEach(() => {
vi.restoreAllMocks()
vi.resetAllMocks()
delete window.__comfyDesktop2
delete window.__comfyDesktop2Remote
})
describe('fetchModelMetadata', () => {
@@ -258,7 +257,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -289,7 +291,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockRejectedValue(bridgeError)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -323,7 +328,10 @@ describe('downloadModel', () => {
.mockImplementation(() => {
throw bridgeError
})
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2 = {
isRemote: () => false,
downloadModel: desktopDownloadModel
}
downloadModel(
{
@@ -353,8 +361,10 @@ describe('downloadModel', () => {
(url: string, filename: string, directory: string) => Promise<boolean>
>()
.mockResolvedValue(true)
window.__comfyDesktop2 = { downloadModel: desktopDownloadModel }
window.__comfyDesktop2Remote = true
window.__comfyDesktop2 = {
isRemote: () => true,
downloadModel: desktopDownloadModel
}
downloadModel(
{

View File

@@ -2,21 +2,7 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
import type { ComfyDesktop2Bridge } from '@/types'
const ALLOWED_SOURCES = [
'https://civitai.com/',
@@ -55,7 +41,7 @@ async function startDesktop2ModelDownload(
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel(model.url, model.name, model.directory)
await bridge.downloadModel?.(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
@@ -90,7 +76,7 @@ export function downloadModel(
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
if (desktop2Bridge?.downloadModel && !desktop2Bridge.isRemote()) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}

View File

@@ -1,5 +1,3 @@
import { api } from '@/scripts/api'
import { remoteConfig, remoteConfigState } from './remoteConfig'
interface RefreshRemoteConfigOptions {
@@ -10,6 +8,13 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
const { api } = await import('@/scripts/api')
return api.fetchApi('/features', { cache: 'no-store' })
}
/**
* Loads remote configuration from the backend /features endpoint
* and updates the reactive remoteConfig ref.
@@ -25,9 +30,7 @@ export async function refreshRemoteConfig(
const { useAuth = true } = options
try {
const response = useAuth
? await api.fetchApi('/features', { cache: 'no-store' })
: await fetch('/api/features', { cache: 'no-store' })
const response = await fetchRemoteConfig(useAuth)
if (response.ok) {
const config = await response.json()

View File

@@ -89,6 +89,7 @@ export type RemoteConfig = {
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
enable_telemetry?: boolean
model_upload_button_enabled?: boolean
asset_rename_enabled?: boolean
private_models_enabled?: boolean

View File

@@ -8,7 +8,6 @@ import type {
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -16,6 +15,7 @@ import type {
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
@@ -107,11 +107,8 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
this.dispatch((provider) => provider.trackRunButton?.(options))
trackRunButton(properties: RunButtonProperties): void {
this.dispatch((provider) => provider.trackRunButton?.(properties))
}
startTopupTracking(): void {

View File

@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { setTelemetryRegistry, useTelemetry } from '@/platform/telemetry'
import { initHostTelemetry } from '@/platform/telemetry/initHostTelemetry'
import { TelemetryEvents } from '@/platform/telemetry/types'
const fetchMock = vi.fn()
const localStorageMock = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}
describe('initHostTelemetry', () => {
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', localStorageMock)
})
afterEach(() => {
remoteConfig.value = {}
setTelemetryRegistry(null)
delete window.__comfyDesktop2
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('leaves the registry untouched when enable_telemetry is on but the host Telemetry bridge is absent', () => {
remoteConfig.value = { enable_telemetry: true }
initHostTelemetry()
expect(useTelemetry()).toBeNull()
expect(fetchMock).not.toHaveBeenCalled()
})
it('leaves the registry untouched when enable_telemetry is off', () => {
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: { capture: vi.fn() }
}
remoteConfig.value = { enable_telemetry: false }
initHostTelemetry()
expect(useTelemetry()).toBeNull()
})
it('registers the host telemetry sink when enable_telemetry and the bridge are present', () => {
const capture = vi.fn()
window.__comfyDesktop2 = { isRemote: () => false, Telemetry: { capture } }
remoteConfig.value = { enable_telemetry: true }
initHostTelemetry()
useTelemetry()?.trackSignupOpened()
expect(capture).toHaveBeenCalledWith(
TelemetryEvents.USER_SIGN_UP_OPENED,
undefined
)
})
})

View File

@@ -0,0 +1,23 @@
import { setTelemetryRegistry } from './index'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import { TelemetryRegistry } from './TelemetryRegistry'
import { HostTelemetrySink } from './providers/host/HostTelemetrySink'
const ENABLE_TELEMETRY_FEATURE = 'enable_telemetry'
function isHostTelemetryEnabled(): boolean {
const override = getDevOverride<boolean>(ENABLE_TELEMETRY_FEATURE)
if (override !== undefined) return override
return remoteConfig.value.enable_telemetry === true
}
export function initHostTelemetry(): void {
if (!isHostTelemetryEnabled()) return
if (!window.__comfyDesktop2?.Telemetry) return
const registry = new TelemetryRegistry()
registry.registerProvider(new HostTelemetrySink())
setTelemetryRegistry(registry)
}

View File

@@ -184,7 +184,21 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
provider.trackRunButton({ trigger_source: 'button' })
provider.trackRunButton({
subscribe_to_run: false,
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'app',
is_app_mode: true
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',

View File

@@ -5,7 +5,6 @@ import type {
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
@@ -13,6 +12,7 @@ import type {
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
@@ -29,6 +29,7 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
/**
* Google Tag Manager telemetry provider.
@@ -152,7 +153,7 @@ export class GtmTelemetryProvider implements TelemetryProvider {
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.pushEvent('begin_checkout', metadata)
this.pushEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackSubscription(
@@ -181,13 +182,12 @@ export class GtmTelemetryProvider implements TelemetryProvider {
)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
trackRunButton(properties: RunButtonProperties): void {
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown'
subscribe_to_run: properties.subscribe_to_run,
trigger_source: properties.trigger_source ?? 'unknown',
view_mode: properties.view_mode,
is_app_mode: properties.is_app_mode
})
}

View File

@@ -17,13 +17,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ onUserResolved: mockOnUserResolved })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'workflow' },
isAppMode: { value: false }
})
}))
const topupMocks = vi.hoisted(() => ({
startTopupTracking: vi.fn(),
clearTopupTracking: vi.fn(),
@@ -31,6 +24,15 @@ const topupMocks = vi.hoisted(() => ({
}))
vi.mock('@/platform/telemetry/topupTracker', () => topupMocks)
const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({
normalizeSurveyResponses: mockNormalizeSurveyResponses
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => ({
is_template: false,
@@ -45,15 +47,6 @@ vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
})
}))
const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({
normalizeSurveyResponses: mockNormalizeSurveyResponses
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: { value: null }
}))
import { MixpanelTelemetryProvider } from '@/platform/telemetry/providers/cloud/MixpanelTelemetryProvider'
import type {
AuthMetadata,
@@ -61,6 +54,7 @@ import type {
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
RunButtonProperties,
ShareFlowMetadata,
SurveyResponses,
TemplateLibraryClosedMetadata,
@@ -401,25 +395,32 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
)
})
it('trackRunButton populates RunButtonProperties from the execution context', async () => {
it('trackRunButton forwards RunButtonProperties', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
provider.trackRunButton({
const properties: RunButtonProperties = {
subscribe_to_run: true,
trigger_source: 'button'
})
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false
}
provider.trackRunButton(properties)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.RUN_BUTTON_CLICKED,
expect.objectContaining({
subscribe_to_run: true,
workflow_type: 'custom',
trigger_source: 'button',
view_mode: 'workflow',
is_app_mode: false
})
properties
)
})
@@ -428,7 +429,21 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
provider.trackRunButton({ trigger_source: 'keybinding' })
provider.trackRunButton({
subscribe_to_run: false,
workflow_type: 'custom',
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'keybinding',
view_mode: 'graph',
is_app_mode: false
})
provider.trackWorkflowExecution()
expect(mockMixpanel.track).toHaveBeenCalledWith(

View File

@@ -1,7 +1,6 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
@@ -11,7 +10,6 @@ import {
import type { AuditLog } from '@/services/customerEventsService'
import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
@@ -277,31 +275,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
clearTopupUtil()
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
trackRunButton(properties: RunButtonProperties): void {
this.lastTriggerSource = properties.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackSurvey(

View File

@@ -48,6 +48,20 @@ vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
getExecutionContext: () => ({
is_template: false,
workflow_name: 'untitled',
custom_node_count: 0,
total_node_count: 0,
subgraph_count: 0,
has_api_nodes: false,
api_node_names: [],
has_toolkit_nodes: false,
toolkit_node_names: []
})
}))
vi.mock('posthog-js', () => hoisted.mockPosthog)
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({

View File

@@ -1,7 +1,6 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -276,31 +275,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: executionContext.is_template ? 'template' : 'custom',
workflow_name: executionContext.workflow_name ?? 'untitled',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
trackRunButton(properties: RunButtonProperties): void {
this.lastTriggerSource = properties.trigger_source
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackSurvey(

View File

@@ -0,0 +1,557 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TelemetryEvents } from '@/platform/telemetry/types'
import type { SurveyResponses } from '@/platform/telemetry/types'
import type { AuditLog } from '@/services/customerEventsService'
import { HostTelemetrySink } from './HostTelemetrySink'
type ForwardCase = [
string,
(sink: HostTelemetrySink) => void,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents],
object | undefined
]
const state = vi.hoisted(() => ({
capture: vi.fn()
}))
const topupMocks = vi.hoisted(() => ({
startTopupTracking: vi.fn(),
checkForCompletedTopup: vi.fn().mockReturnValue(true),
clearTopupTracking: vi.fn()
}))
vi.mock('@/platform/telemetry/topupTracker', () => topupMocks)
const mockNormalizeSurveyResponses = vi.hoisted(() =>
vi.fn((responses: SurveyResponses) => ({
...responses,
industry_normalized: 'Software / IT / AI'
}))
)
vi.mock('../../utils/surveyNormalization', () => ({
normalizeSurveyResponses: mockNormalizeSurveyResponses
}))
describe('HostTelemetrySink', () => {
beforeEach(() => {
state.capture.mockClear()
topupMocks.startTopupTracking.mockClear()
topupMocks.checkForCompletedTopup.mockClear()
topupMocks.checkForCompletedTopup.mockReturnValue(true)
topupMocks.clearTopupTracking.mockClear()
mockNormalizeSurveyResponses.mockClear()
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: {
capture: state.capture
}
}
})
afterEach(() => {
delete window.__comfyDesktop2
})
it('forwards run button telemetry to the host bridge', () => {
new HostTelemetrySink().trackRunButton({
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'Host workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.RUN_BUTTON_CLICKED,
{
subscribe_to_run: true,
workflow_type: 'custom',
workflow_name: 'Host workflow',
custom_node_count: 2,
total_node_count: 4,
subgraph_count: 1,
has_api_nodes: true,
api_node_names: ['LoadImage'],
has_toolkit_nodes: false,
toolkit_node_names: [],
trigger_source: 'button',
view_mode: 'graph',
is_app_mode: false
}
)
})
it('keeps primitive arrays and drops nested payloads', () => {
new HostTelemetrySink().trackWorkflowImported({
missing_node_count: 2,
missing_node_types: ['MissingA', 'MissingB'],
open_source: 'file_drop'
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.WORKFLOW_IMPORTED,
{
missing_node_count: 2,
missing_node_types: ['MissingA', 'MissingB'],
open_source: 'file_drop'
}
)
})
it('forwards begin checkout using the existing GA4 event name', () => {
new HostTelemetrySink().trackBeginCheckout({
user_id: 'user-id',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
ecommerce: {
items: [
{
item_name: 'Pro',
price: 100,
quantity: 1
}
]
}
})
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.BEGIN_CHECKOUT,
{
user_id: 'user-id',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
}
)
})
it('does nothing when the host bridge is absent', () => {
delete window.__comfyDesktop2
expect(() =>
new HostTelemetrySink().trackNodeSearch({ query: 'k sampler' })
).not.toThrow()
expect(state.capture).not.toHaveBeenCalled()
})
it.for<ForwardCase>([
[
'trackSignupOpened',
(sink: HostTelemetrySink) => sink.trackSignupOpened(),
TelemetryEvents.USER_SIGN_UP_OPENED,
undefined
],
[
'trackAuth',
(sink: HostTelemetrySink) =>
sink.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-id'
}),
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-id'
}
],
[
'trackUserLoggedIn',
(sink: HostTelemetrySink) => sink.trackUserLoggedIn(),
TelemetryEvents.USER_LOGGED_IN,
undefined
],
[
'trackSubscription modal',
(sink: HostTelemetrySink) =>
sink.trackSubscription('modal_opened', {
current_tier: 'free',
reason: 'out_of_credits'
}),
TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED,
{
current_tier: 'free',
reason: 'out_of_credits'
}
],
[
'trackSubscription subscribe',
(sink: HostTelemetrySink) =>
sink.trackSubscription('subscribe_clicked', { current_tier: 'free' }),
TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED,
{ current_tier: 'free' }
],
[
'trackMonthlySubscriptionSucceeded',
(sink: HostTelemetrySink) =>
sink.trackMonthlySubscriptionSucceeded({
user_id: 'user-id',
checkout_attempt_id: 'attempt-id',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
value: 100,
currency: 'USD',
ecommerce: {
currency: 'USD',
value: 100,
items: [
{
item_name: 'Pro',
item_category: 'subscription',
price: 100,
quantity: 1
}
]
}
}),
TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED,
{
user_id: 'user-id',
checkout_attempt_id: 'attempt-id',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
value: 100,
currency: 'USD'
}
],
[
'trackMonthlySubscriptionCancelled',
(sink: HostTelemetrySink) => sink.trackMonthlySubscriptionCancelled(),
TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED,
undefined
],
[
'trackAddApiCreditButtonClicked',
(sink: HostTelemetrySink) => sink.trackAddApiCreditButtonClicked(),
TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED,
undefined
],
[
'trackApiCreditTopupButtonPurchaseClicked',
(sink: HostTelemetrySink) =>
sink.trackApiCreditTopupButtonPurchaseClicked(25),
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
{ credit_amount: 25 }
],
[
'trackApiCreditTopupSucceeded',
(sink: HostTelemetrySink) => sink.trackApiCreditTopupSucceeded(),
TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED,
undefined
],
[
'trackTemplate',
(sink: HostTelemetrySink) =>
sink.trackTemplate({
workflow_name: 'Template',
template_source: 'library'
}),
TelemetryEvents.TEMPLATE_WORKFLOW_OPENED,
{
workflow_name: 'Template',
template_source: 'library'
}
],
[
'trackTemplateLibraryOpened',
(sink: HostTelemetrySink) =>
sink.trackTemplateLibraryOpened({ source: 'sidebar' }),
TelemetryEvents.TEMPLATE_LIBRARY_OPENED,
{ source: 'sidebar' }
],
[
'trackTemplateLibraryClosed',
(sink: HostTelemetrySink) =>
sink.trackTemplateLibraryClosed({
template_selected: true,
time_spent_seconds: 3
}),
TelemetryEvents.TEMPLATE_LIBRARY_CLOSED,
{
template_selected: true,
time_spent_seconds: 3
}
],
[
'trackWorkflowOpened',
(sink: HostTelemetrySink) =>
sink.trackWorkflowOpened({
missing_node_count: 1,
missing_node_types: ['MissingNode'],
open_source: 'file_button'
}),
TelemetryEvents.WORKFLOW_OPENED,
{
missing_node_count: 1,
missing_node_types: ['MissingNode'],
open_source: 'file_button'
}
],
[
'trackWorkflowSaved',
(sink: HostTelemetrySink) =>
sink.trackWorkflowSaved({ is_app: true, is_new: false }),
TelemetryEvents.WORKFLOW_SAVED,
{ is_app: true, is_new: false }
],
[
'trackDefaultViewSet',
(sink: HostTelemetrySink) =>
sink.trackDefaultViewSet({ default_view: 'app' }),
TelemetryEvents.DEFAULT_VIEW_SET,
{ default_view: 'app' }
],
[
'trackEnterLinear',
(sink: HostTelemetrySink) => sink.trackEnterLinear({ source: 'toolbar' }),
TelemetryEvents.ENTER_LINEAR_MODE,
{ source: 'toolbar' }
],
[
'trackShareFlow',
(sink: HostTelemetrySink) =>
sink.trackShareFlow({ step: 'link_created', source: 'app_mode' }),
TelemetryEvents.SHARE_FLOW,
{ step: 'link_created', source: 'app_mode' }
],
[
'trackPageVisibilityChanged',
(sink: HostTelemetrySink) =>
sink.trackPageVisibilityChanged({ visibility_state: 'visible' }),
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
{ visibility_state: 'visible' }
],
[
'trackTabCount',
(sink: HostTelemetrySink) => sink.trackTabCount({ tab_count: 2 }),
TelemetryEvents.TAB_COUNT_TRACKING,
{ tab_count: 2 }
],
[
'trackNodeSearch',
(sink: HostTelemetrySink) => sink.trackNodeSearch({ query: 'ksampler' }),
TelemetryEvents.NODE_SEARCH,
{ query: 'ksampler' }
],
[
'trackNodeSearchResultSelected',
(sink: HostTelemetrySink) =>
sink.trackNodeSearchResultSelected({
node_type: 'KSampler',
last_query: 'sampler'
}),
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
{ node_type: 'KSampler', last_query: 'sampler' }
],
[
'trackTemplateFilterChanged',
(sink: HostTelemetrySink) =>
sink.trackTemplateFilterChanged({
selected_models: ['flux'],
selected_use_cases: ['image'],
selected_runs_on: ['local'],
sort_by: 'popular',
filtered_count: 4,
total_count: 12
}),
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
{
selected_models: ['flux'],
selected_use_cases: ['image'],
selected_runs_on: ['local'],
sort_by: 'popular',
filtered_count: 4,
total_count: 12
}
],
[
'trackHelpCenterOpened',
(sink: HostTelemetrySink) =>
sink.trackHelpCenterOpened({ source: 'menu' }),
TelemetryEvents.HELP_CENTER_OPENED,
{ source: 'menu' }
],
[
'trackHelpResourceClicked',
(sink: HostTelemetrySink) =>
sink.trackHelpResourceClicked({
resource_type: 'docs',
is_external: true,
source: 'help_center'
}),
TelemetryEvents.HELP_RESOURCE_CLICKED,
{
resource_type: 'docs',
is_external: true,
source: 'help_center'
}
],
[
'trackHelpCenterClosed',
(sink: HostTelemetrySink) =>
sink.trackHelpCenterClosed({ time_spent_seconds: 5 }),
TelemetryEvents.HELP_CENTER_CLOSED,
{ time_spent_seconds: 5 }
],
[
'trackWorkflowCreated',
(sink: HostTelemetrySink) =>
sink.trackWorkflowCreated({
workflow_type: 'blank',
previous_workflow_had_nodes: false
}),
TelemetryEvents.WORKFLOW_CREATED,
{
workflow_type: 'blank',
previous_workflow_had_nodes: false
}
],
[
'trackWorkflowExecution',
(sink: HostTelemetrySink) => sink.trackWorkflowExecution(),
TelemetryEvents.EXECUTION_START,
undefined
],
[
'trackExecutionError',
(sink: HostTelemetrySink) =>
sink.trackExecutionError({
jobId: 'job-id',
nodeId: 'node-id',
nodeType: 'KSampler',
error: 'failed'
}),
TelemetryEvents.EXECUTION_ERROR,
{
jobId: 'job-id',
nodeId: 'node-id',
nodeType: 'KSampler',
error: 'failed'
}
],
[
'trackExecutionSuccess',
(sink: HostTelemetrySink) =>
sink.trackExecutionSuccess({ jobId: 'job-id' }),
TelemetryEvents.EXECUTION_SUCCESS,
{ jobId: 'job-id' }
],
[
'trackSettingChanged',
(sink: HostTelemetrySink) =>
sink.trackSettingChanged({
setting_id: 'Comfy.Test',
previous_value: 'off',
new_value: 'on'
}),
TelemetryEvents.SETTING_CHANGED,
{
setting_id: 'Comfy.Test',
previous_value: 'off',
new_value: 'on'
}
],
[
'trackUiButtonClicked',
(sink: HostTelemetrySink) =>
sink.trackUiButtonClicked({ button_id: 'comfy_logo' }),
TelemetryEvents.UI_BUTTON_CLICKED,
{ button_id: 'comfy_logo' }
],
[
'trackPageView',
(sink: HostTelemetrySink) =>
sink.trackPageView('Settings', {
path: '/settings',
title: 'Settings'
}),
TelemetryEvents.PAGE_VIEW,
{
page_name: 'Settings',
path: '/settings',
title: 'Settings'
}
]
])('forwards %s to the host bridge', ([_, track, event, properties]) => {
track(new HostTelemetrySink())
expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, properties)
})
it.for<
[
'opened' | 'submitted',
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['opened' as const, TelemetryEvents.USER_SURVEY_OPENED],
['submitted' as const, TelemetryEvents.USER_SURVEY_SUBMITTED]
])('normalizes survey responses for %s events', ([stage, event]) => {
const responses = { industry: 'software' }
new HostTelemetrySink().trackSurvey(stage, responses)
expect(mockNormalizeSurveyResponses).toHaveBeenCalledExactlyOnceWith(
responses
)
expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, {
industry: 'software',
industry_normalized: 'Software / IT / AI'
})
})
it('forwards survey events without responses', () => {
new HostTelemetrySink().trackSurvey('opened')
expect(mockNormalizeSurveyResponses).not.toHaveBeenCalled()
expect(state.capture).toHaveBeenCalledExactlyOnceWith(
TelemetryEvents.USER_SURVEY_OPENED,
undefined
)
})
it.for<
[
'opened' | 'requested' | 'completed',
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['opened' as const, TelemetryEvents.USER_EMAIL_VERIFY_OPENED],
['requested' as const, TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED],
['completed' as const, TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED]
])('forwards email verification %s events', ([stage, event]) => {
new HostTelemetrySink().trackEmailVerification(stage)
expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, undefined)
})
it('delegates topup tracking to the shared tracker', () => {
const events = [{ event_type: 'credit_added' }] as AuditLog[]
const sink = new HostTelemetrySink()
sink.startTopupTracking()
const completed = sink.checkForCompletedTopup(events)
sink.clearTopupTracking()
expect(topupMocks.startTopupTracking).toHaveBeenCalledOnce()
expect(topupMocks.checkForCompletedTopup).toHaveBeenCalledExactlyOnceWith(
events
)
expect(completed).toBe(true)
expect(topupMocks.clearTopupTracking).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,275 @@
import type {
ComfyDesktop2TelemetryBridge,
ComfyDesktop2TelemetryValue
} from '@comfyorg/comfyui-desktop-bridge-types'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
type HostTelemetryProperties = Parameters<
ComfyDesktop2TelemetryBridge['capture']
>[1]
function isHostTelemetryPrimitive(
value: unknown
): value is ComfyDesktop2TelemetryValue {
return (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
)
}
function toHostTelemetryProperties(
properties?: object
): HostTelemetryProperties {
if (!properties) return undefined
const out: NonNullable<HostTelemetryProperties> = {}
for (const [key, value] of Object.entries(properties)) {
if (isHostTelemetryPrimitive(value)) {
out[key] = value
} else if (Array.isArray(value) && value.every(isHostTelemetryPrimitive)) {
out[key] = value
}
}
return out
}
export class HostTelemetrySink implements TelemetryProvider {
private capture(event: TelemetryEventName, properties?: object): void {
window.__comfyDesktop2?.Telemetry?.capture(
event,
toHostTelemetryProperties(properties)
)
}
trackSignupOpened(): void {
this.capture(TelemetryEvents.USER_SIGN_UP_OPENED)
}
trackAuth(metadata: AuthMetadata): void {
this.capture(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
this.capture(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
this.capture(
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED,
metadata
)
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.capture(TelemetryEvents.BEGIN_CHECKOUT, metadata)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata)
}
trackMonthlySubscriptionCancelled(): void {
this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
}
trackAddApiCreditButtonClicked(): void {
this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.capture(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, {
credit_amount: amount
})
}
trackApiCreditTopupSucceeded(): void {
this.capture(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(properties: RunButtonProperties): void {
this.capture(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
startTopupTracking(): void {
startTopupUtil()
}
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean {
return checkTopupUtil(events)
}
clearTopupTracking(): void {
clearTopupUtil()
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
this.capture(
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED,
responses ? normalizeSurveyResponses(responses) : undefined
)
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
const event =
stage === 'opened'
? TelemetryEvents.USER_EMAIL_VERIFY_OPENED
: stage === 'requested'
? TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
: TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
this.capture(event)
}
trackTemplate(metadata: TemplateMetadata): void {
this.capture(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.capture(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.capture(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.capture(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.capture(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.capture(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.capture(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.capture(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.capture(TelemetryEvents.SHARE_FLOW, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.capture(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.capture(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.capture(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.capture(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.capture(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.capture(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.capture(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.capture(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.capture(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
this.capture(TelemetryEvents.EXECUTION_START)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.capture(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.capture(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.capture(TelemetryEvents.SETTING_CHANGED, metadata)
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.capture(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.capture(TelemetryEvents.PAGE_VIEW, {
page_name: pageName,
...properties
})
}
}

View File

@@ -16,6 +16,7 @@ import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/com
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { AuditLog } from '@/services/customerEventsService'
import type { AppMode } from '@/utils/appMode'
/**
* Authentication metadata for sign-up tracking
@@ -69,8 +70,8 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode?: string
is_app_mode?: boolean
view_mode: AppMode
is_app_mode: boolean
}
/**
@@ -398,10 +399,7 @@ export interface TelemetryProvider {
trackAddApiCreditButtonClicked?(): void
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
trackApiCreditTopupSucceeded?(): void
trackRunButton?(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void
trackRunButton?(properties: RunButtonProperties): void
// Credit top-up tracking (composition with internal utilities)
startTopupTracking?(): void
@@ -493,6 +491,7 @@ export const TelemetryEvents = {
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
BEGIN_CHECKOUT: 'begin_checkout',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',

View File

@@ -18,9 +18,9 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { AppMode } from '@/utils/appMode'
import { t } from '@/i18n'
function createModeTestWorkflow(

View File

@@ -23,7 +23,6 @@ import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -37,6 +36,7 @@ import {
appendWorkflowJsonExt,
generateUUID
} from '@/utils/formatUtil'
import type { AppMode } from '@/utils/appMode'
function linearModeToAppMode(linearMode: unknown): AppMode | null {
if (typeof linearMode !== 'boolean') return null

View File

@@ -2,13 +2,13 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingNodeType } from '@/types/comfy'
import type { AppMode } from '@/utils/appMode'
export interface InputWidgetConfig {
height?: number

View File

@@ -1,11 +1,11 @@
import { useSettingStore } from '@/platform/settings/settingStore'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { isCloud } from '@/platform/distribution/types'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats'
import { type StatusWsMessageStatus } from '@/schemas/apiSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -487,10 +487,10 @@ export class ComfyUI {
id: 'queue-button',
textContent: 'Queue Prompt',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
useRunButtonTelemetry().trackRunButton({
trigger_source: 'legacy_ui'
})
useTelemetry()?.trackWorkflowExecution()
app.queuePrompt(0, this.batchCount)
}
}),
@@ -595,10 +595,10 @@ export class ComfyUI {
id: 'queue-front-button',
textContent: 'Queue Front',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
useRunButtonTelemetry().trackRunButton({
trigger_source: 'legacy_ui'
})
useTelemetry()?.trackWorkflowExecution()
app.queuePrompt(-1, this.batchCount)
}
}),

View File

@@ -15,12 +15,16 @@ const {
mockNodeExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeLocatorIdToNodeExecutionId,
mockShowTextPreview
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess
} = vi.hoisted(() => ({
mockNodeExecutionIdToNodeLocatorId: vi.fn(),
mockNodeIdToNodeLocatorId: vi.fn(),
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn()
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn()
}))
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -82,6 +86,13 @@ vi.mock('@/stores/jobPreviewStore', () => ({
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackExecutionError: mockTrackExecutionError,
trackExecutionSuccess: mockTrackExecutionSuccess
})
}))
// Mock the app import with proper implementation
vi.mock('@/scripts/app', () => ({
app: {
@@ -1085,6 +1096,12 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.activeJobId).toBeNull()
expect(store.queuedJobs['job-1']).toBeUndefined()
})
it('does not track success for jobs this client did not queue', () => {
fire('execution_success', { prompt_id: 'foreign-job', timestamp: 0 })
expect(mockTrackExecutionSuccess).not.toHaveBeenCalled()
})
})
describe('executing', () => {

View File

@@ -295,12 +295,14 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
if (isCloud && activeJobId.value) {
useTelemetry()?.trackExecutionSuccess({
jobId: activeJobId.value
const jobId = e.detail.prompt_id
const queuedJob = queuedJobs.value[jobId]
const telemetry = useTelemetry()
if (queuedJob) {
telemetry?.trackExecutionSuccess({
jobId
})
}
const jobId = e.detail.prompt_id
resetExecutionState(jobId)
}
@@ -398,14 +400,14 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
if (isCloud) {
useTelemetry()?.trackExecutionError({
jobId: e.detail.prompt_id,
nodeId: String(e.detail.node_id),
nodeType: e.detail.node_type,
error: e.detail.exception_message
})
useTelemetry()?.trackExecutionError({
jobId: e.detail.prompt_id,
nodeId: String(e.detail.node_id),
nodeType: e.detail.node_type,
error: e.detail.exception_message
})
if (isCloud) {
// Cloud wraps validation errors (400) in exception_message as embedded JSON.
if (handleCloudValidationError(e.detail)) return
}

View File

@@ -1,3 +1,4 @@
import type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
import type {
DeviceStats,
EmbeddingsResponse,
@@ -25,6 +26,7 @@ import type {
} from './extensionTypes'
export type { ComfyExtension } from './comfy'
export type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types'
export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
@@ -88,5 +90,7 @@ declare global {
/** For use in tests to track app initialization state */
__appReadiness?: AppReadiness
__comfyDesktop2?: ComfyDesktop2Bridge
}
}

21
src/utils/appMode.ts Normal file
View File

@@ -0,0 +1,21 @@
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}