mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8aa4382a9 | ||
|
|
e2f492b07b | ||
|
|
cad2742ad6 | ||
|
|
f718200bb4 |
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
Normal file
142
.github/workflows/publish-desktop-bridge-types.yaml
vendored
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
91
packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
vendored
Normal file
91
packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
vendored
Normal 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]>
|
||||
}
|
||||
1
packages/comfyui-desktop-bridge-types/index.d.ts
vendored
Normal file
1
packages/comfyui-desktop-bridge-types/index.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './comfyDesktopBridge.js'
|
||||
1
packages/comfyui-desktop-bridge-types/index.js
Normal file
1
packages/comfyui-desktop-bridge-types/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {}
|
||||
27
packages/comfyui-desktop-bridge-types/package.json
Normal file
27
packages/comfyui-desktop-bridge-types/package.json
Normal 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
5
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
108
src/composables/useRunButtonTelemetry.test.ts
Normal file
108
src/composables/useRunButtonTelemetry.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
50
src/composables/useRunButtonTelemetry.ts
Normal file
50
src/composables/useRunButtonTelemetry.ts
Normal 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 }
|
||||
}
|
||||
16
src/main.ts
16
src/main.ts
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
64
src/platform/telemetry/initHostTelemetry.test.ts
Normal file
64
src/platform/telemetry/initHostTelemetry.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
23
src/platform/telemetry/initHostTelemetry.ts
Normal file
23
src/platform/telemetry/initHostTelemetry.ts
Normal 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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
557
src/platform/telemetry/providers/host/HostTelemetrySink.test.ts
Normal file
557
src/platform/telemetry/providers/host/HostTelemetrySink.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
275
src/platform/telemetry/providers/host/HostTelemetrySink.ts
Normal file
275
src/platform/telemetry/providers/host/HostTelemetrySink.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
21
src/utils/appMode.ts
Normal 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'
|
||||
}
|
||||
Reference in New Issue
Block a user