Compare commits

...

11 Commits

Author SHA1 Message Date
Benjamin Lu
195b766f86 Desktop maintenance: unsafe base path warning (#6750)
Surface unsafe base path validation in the desktop maintenance view and
add an installation-fix auto-refresh after successful tasks.

<img width="1080" height="870" alt="image"
src="https://github.com/user-attachments/assets/26fe61be-fed8-47c0-a921-604f0af018f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6750-Desktop-maintenance-unsafe-base-path-warning-2b06d73d36508147aeb4d19d02bbf0f0)
by [Unito](https://www.unito.io)
2025-11-20 02:13:54 +00:00
sno
e1706a8f13 fix: Remove assignees from weekly-docs-check workflow (#6375)
## Summary

Removes the `assignees` field from the weekly-docs-check workflow that
was causing failures.

## Problem

The workflow was attempting to assign `${{ github.repository_owner }}`
to created PRs. For organization-owned repositories,
`github.repository_owner` is the organization name (e.g., "Comfy-Org"),
which cannot be assigned to pull requests. Only individual GitHub users
can be assigned.

This was causing the workflow to fail:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/18901589988

## Solution

Removed the `assignees` line from the workflow configuration. The PRs
will still be created successfully, just without auto-assignment.

## Changes Made

- Removed `assignees: ${{ github.repository_owner }}` from
`.github/workflows/weekly-docs-check.yaml:145`

Fixes #6364

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6375-fix-Remove-assignees-from-weekly-docs-check-workflow-29b6d73d365081769ec2dc3e68005cc7)
by [Unito](https://www.unito.io)
2025-10-30 20:47:07 -07:00
Christian Byrne
1322a56653 Cloud/tracking v2 (#6400)
## Summary

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

## Changes

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

## Review Focus

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

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

## Screenshots (if applicable)

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

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

---------

Co-authored-by: Arjan Singh <arjan@comfy.org>
2025-10-30 20:46:42 -07:00
AustinMroz
6491db6f68 Further subgraph improvements (#6466)
- Prune disconnected widgets on config panel open
- Fix breakage of DOMWidgets on double nest
- Fix self clipping of proxied DOMWidget nodes
- Blacklist cloning of promtoed widget prop

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6466-Further-subgraph-improvements-29c6d73d365081c8b63dda068486805c)
by [Unito](https://www.unito.io)
2025-10-30 18:06:10 -07:00
Simula_r
041ce2decb Feat/vue nodes autoscale false (#6469)
## Summary

Switch back to false, for now. Because its a destructive action. Edge
cases and user information can come next. This saves us time so we can
cut a nightly release and move on.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6469-Feat-vue-nodes-autoscale-false-29c6d73d365081deabf0dc284a9b41ab)
by [Unito](https://www.unito.io)
2025-10-30 15:23:57 -07:00
Simula_r
ce298c43f4 feat: Comfy.VueNodes.AutoScaleLayout default true (#6467)
## Summary

Now that banner and toast are merged run the auto scale / node arrange
function by default.

## Changes

- **What**: Core settings Comfy.VueNodes.AutoScaleLayout defaultValue:
true,

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6467-feat-Comfy-VueNodes-AutoScaleLayout-default-true-29c6d73d365081c2b317f7f3450cbaec)
by [Unito](https://www.unito.io)
2025-10-30 14:29:55 -07:00
AustinMroz
40a7cbb83e Fix selectionToolbox not being on node (#6401)
`getBounding()` doesn't work if a draw hasn't occurred.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6401-Fix-selectionToolbox-not-being-on-node-29c6d73d3650812cbfbff116a9b8159d)
by [Unito](https://www.unito.io)
2025-10-30 12:13:00 -07:00
AustinMroz
9ddead24d6 On failed subgraph resolution, return undefined (#6460)
Resolving an executionId to a locatorId can fail if the current workflow
does not match the queued one. Rather than throwing an error, the
resolution functions have been changed to return undefined.

Of note, all consumers of these functions already had checks to ensure
the returned value is not undefined.
- Even the test for failure state already specified that it should
return instead of throwing an error.

This PR cleans up a frequent sentry error.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6460-On-failed-subgraph-resolution-return-undefined-29c6d73d365081c5860ed7d24a99414c)
by [Unito](https://www.unito.io)
2025-10-30 12:06:52 -07:00
Arjan Singh
6186e81b98 chore: add example sentry env vars (#6462)
## Summary

Add examples for future debugging documentations.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6462-chore-add-example-sentry-env-vars-29c6d73d365081b5bf06f8f248985fbf)
by [Unito](https://www.unito.io)
2025-10-30 11:05:15 -07:00
Arjan Singh
04eb224822 ci: add sentryVitePlugin (#6394)
## Summary

This will be used to upload source maps in configured environments.

Docs:
https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/vite/

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6394-ci-add-sentryVitePlugin-29c6d73d365081239f48f2fd261736d5)
by [Unito](https://www.unito.io)
2025-10-30 10:28:48 -07:00
Terry Jia
dd90288846 code improve for 3d node (#6403)
## Summary

code improve for 3d node

## Changes

1. remove custom css
2. use tailwind instead
3. bug fix for right click context menu

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6403-code-improve-for-3d-node-29c6d73d3650819fab26feabdaa21821)
by [Unito](https://www.unito.io)
2025-10-30 05:31:36 -04:00
37 changed files with 2240 additions and 60 deletions

View File

@@ -23,10 +23,10 @@ TEST_COMFYUI_DIR=/home/ComfyUI
# Whether to enable minification of the frontend code.
ENABLE_MINIFY=true
# Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site
# Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site
# packages.
DISABLE_TEMPLATES_PROXY=false
@@ -37,3 +37,8 @@ DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging

View File

@@ -142,4 +142,3 @@ jobs:
documentation
automated
draft: true
assignees: ${{ github.repository_owner }}

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI()
// Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing)
)
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
}
return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute,
getRunner,
processUpdate,

View File

@@ -0,0 +1,159 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,6 +47,28 @@
</div>
</div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -139,6 +161,27 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
const isValid = await electron.Validation.complete()

View File

@@ -57,6 +57,7 @@
"@pinia/testing": "catalog:",
"@playwright/test": "catalog:",
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",

View File

@@ -1198,31 +1198,6 @@ audio.comfy-audio.empty-audio-widget {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
/* Load3d styles */
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation {
display: flex;
flex-direction: column;
background: transparent;
flex: 1;
position: relative;
overflow: hidden;
}
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas,
.comfy-load-3d-viewer canvas {
display: flex;
width: 100% !important;
height: 100% !important;
}
/* End of Load3d styles */
/* [Desktop] Electron window specific styles */
.app-drag {
app-region: drag;

224
pnpm-lock.yaml generated
View File

@@ -69,6 +69,9 @@ catalogs:
'@primevue/themes':
specifier: ^4.2.5
version: 4.2.5
'@sentry/vite-plugin':
specifier: ^4.6.0
version: 4.6.0
'@sentry/vue':
specifier: ^8.48.0
version: 8.48.0
@@ -498,6 +501,9 @@ importers:
'@prettier/plugin-oxc':
specifier: 'catalog:'
version: 0.0.4
'@sentry/vite-plugin':
specifier: 'catalog:'
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 9.1.1(@types/react@19.1.9)(storybook@9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)))
@@ -2772,14 +2778,78 @@ packages:
resolution: {integrity: sha512-csILVupc5RkrsTrncuUTGmlB56FQSFjXPYWG8I8yBTGlXEJ+o8oTuF6+55R4vbw3EIzBveXWi4kEBbnQlXW/eg==}
engines: {node: '>=14.18'}
'@sentry/babel-plugin-component-annotate@4.6.0':
resolution: {integrity: sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ==}
engines: {node: '>= 14'}
'@sentry/browser@8.48.0':
resolution: {integrity: sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==}
engines: {node: '>=14.18'}
'@sentry/bundler-plugin-core@4.6.0':
resolution: {integrity: sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g==}
engines: {node: '>= 14'}
'@sentry/cli-darwin@2.57.0':
resolution: {integrity: sha512-v1wYQU3BcCO+Z3OVxxO+EnaW4oQhuOza6CXeYZ0z5ftza9r0QQBLz3bcZKTVta86xraNm0z8GDlREwinyddOxQ==}
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-linux-arm64@2.57.0':
resolution: {integrity: sha512-Kh1jTsMV5Fy/RvB381N/woXe1qclRMqsG6kM3Gq6m6afEF/+k3PyQdNW3HXAola6d63EptokLtxPG2xjWQ+w9Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd, android]
'@sentry/cli-linux-arm@2.57.0':
resolution: {integrity: sha512-uNHB8xyygqfMd1/6tFzl9NUkuVefg7jdZtM/vVCQVaF/rJLWZ++Wms+LLhYyKXKN8yd7J9wy7kTEl4Qu4jWbGQ==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd, android]
'@sentry/cli-linux-i686@2.57.0':
resolution: {integrity: sha512-EYXghoK/tKd0zqz+KD/ewXXE3u1HLCwG89krweveytBy/qw7M5z58eFvw+iGb1Vnbl1f/fRD0G4E0AbEsPfmpg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd, android]
'@sentry/cli-linux-x64@2.57.0':
resolution: {integrity: sha512-CyZrP/ssHmAPLSzfd4ydy7icDnwmDD6o3QjhkWwVFmCd+9slSBMQxpIqpamZmrWE6X4R+xBRbSUjmdoJoZ5yMw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd, android]
'@sentry/cli-win32-arm64@2.57.0':
resolution: {integrity: sha512-wji/GGE4Lh5I/dNCsuVbg6fRvttvZRG6db1yPW1BSvQRh8DdnVy1CVp+HMqSq0SRy/S4z60j2u+m4yXMoCL+5g==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@sentry/cli-win32-i686@2.57.0':
resolution: {integrity: sha512-hWvzyD7bTPh3b55qvJ1Okg3Wbl0Km8xcL6KvS7gfBl6uss+I6RldmQTP0gJKdHSdf/QlJN1FK0b7bLnCB3wHsg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-x64@2.57.0':
resolution: {integrity: sha512-QWYV/Y0sbpDSTyA4XQBOTaid4a6H2Iwa1Z8UI+qNxFlk0ADSEgIqo2NrRHDU8iRnghTkecQNX1NTt/7mXN3f/A==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli@2.57.0':
resolution: {integrity: sha512-oC4HPrVIX06GvUTgK0i+WbNgIA9Zl5YEcwf9N4eWFJJmjonr2j4SML9Hn2yNENbUWDgwepy4MLod3P8rM4bk/w==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@8.48.0':
resolution: {integrity: sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==}
engines: {node: '>=14.18'}
'@sentry/vite-plugin@4.6.0':
resolution: {integrity: sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw==}
engines: {node: '>= 14'}
'@sentry/vue@8.48.0':
resolution: {integrity: sha512-hqm9X7hz1vMQQB1HBYezrDBQihZk6e/MxWIG1wMJoClcBnD1Sh7y+D36UwaQlR4Gr/Ftiz+Bb0DxuAYHoUS4ow==}
engines: {node: '>=14.18'}
@@ -3686,6 +3756,10 @@ packages:
resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==}
engines: {node: '>= 10.0.0'}
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -4959,6 +5033,9 @@ packages:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5039,6 +5116,10 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
engines: {node: '>=16 || 14 >=14.17'}
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
@@ -5168,6 +5249,10 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -5853,6 +5938,10 @@ packages:
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magic-string@0.30.8:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
@@ -6073,6 +6162,10 @@ packages:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@8.0.4:
resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6088,6 +6181,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@4.2.8:
resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==}
engines: {node: '>=8'}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -6527,6 +6624,10 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
promise@7.3.1:
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
@@ -7426,6 +7527,9 @@ packages:
'@nuxt/kit':
optional: true
unplugin@1.0.1:
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
unplugin@1.16.1:
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
engines: {node: '>=14.0.0'}
@@ -7699,6 +7803,13 @@ packages:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-sources@3.3.3:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.5.0:
resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
@@ -10205,6 +10316,8 @@ snapshots:
'@sentry-internal/browser-utils': 8.48.0
'@sentry/core': 8.48.0
'@sentry/babel-plugin-component-annotate@4.6.0': {}
'@sentry/browser@8.48.0':
dependencies:
'@sentry-internal/browser-utils': 8.48.0
@@ -10213,8 +10326,74 @@ snapshots:
'@sentry-internal/replay-canvas': 8.48.0
'@sentry/core': 8.48.0
'@sentry/bundler-plugin-core@4.6.0':
dependencies:
'@babel/core': 7.27.1
'@sentry/babel-plugin-component-annotate': 4.6.0
'@sentry/cli': 2.57.0
dotenv: 16.6.1
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/cli-darwin@2.57.0':
optional: true
'@sentry/cli-linux-arm64@2.57.0':
optional: true
'@sentry/cli-linux-arm@2.57.0':
optional: true
'@sentry/cli-linux-i686@2.57.0':
optional: true
'@sentry/cli-linux-x64@2.57.0':
optional: true
'@sentry/cli-win32-arm64@2.57.0':
optional: true
'@sentry/cli-win32-i686@2.57.0':
optional: true
'@sentry/cli-win32-x64@2.57.0':
optional: true
'@sentry/cli@2.57.0':
dependencies:
https-proxy-agent: 5.0.1
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
optionalDependencies:
'@sentry/cli-darwin': 2.57.0
'@sentry/cli-linux-arm': 2.57.0
'@sentry/cli-linux-arm64': 2.57.0
'@sentry/cli-linux-i686': 2.57.0
'@sentry/cli-linux-x64': 2.57.0
'@sentry/cli-win32-arm64': 2.57.0
'@sentry/cli-win32-i686': 2.57.0
'@sentry/cli-win32-x64': 2.57.0
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/core@8.48.0': {}
'@sentry/vite-plugin@4.6.0':
dependencies:
'@sentry/bundler-plugin-core': 4.6.0
unplugin: 1.0.1
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/vue@8.48.0(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))':
dependencies:
'@sentry/browser': 8.48.0
@@ -11214,6 +11393,12 @@ snapshots:
address@1.2.2: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
agent-base@7.1.4: {}
agentkeepalive@4.6.0:
@@ -12713,6 +12898,8 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
@@ -12807,6 +12994,13 @@ snapshots:
package-json-from-dist: 1.0.0
path-scurry: 2.0.0
glob@9.3.5:
dependencies:
fs.realpath: 1.0.0
minimatch: 8.0.4
minipass: 4.2.8
path-scurry: 1.11.1
global-directory@4.0.1:
dependencies:
ini: 4.1.1
@@ -12937,6 +13131,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -13626,6 +13827,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.8:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.28.4
@@ -14031,6 +14236,10 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minimatch@8.0.4:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.1:
dependencies:
brace-expansion: 2.0.2
@@ -14045,6 +14254,8 @@ snapshots:
minimist@1.2.8: {}
minipass@4.2.8: {}
minipass@7.1.2: {}
minizlib@3.0.2:
@@ -14550,6 +14761,8 @@ snapshots:
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
promise@7.3.1:
dependencies:
asap: 2.0.6
@@ -15689,6 +15902,13 @@ snapshots:
- rollup
- supports-color
unplugin@1.0.1:
dependencies:
acorn: 8.15.0
chokidar: 3.6.0
webpack-sources: 3.3.3
webpack-virtual-modules: 0.5.0
unplugin@1.16.1:
dependencies:
acorn: 8.15.0
@@ -16037,6 +16257,10 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-sources@3.3.3: {}
webpack-virtual-modules@0.5.0: {}
webpack-virtual-modules@0.6.2: {}
websocket-driver@0.7.4:

View File

@@ -24,6 +24,7 @@ catalog:
'@primevue/forms': ^4.2.5
'@primevue/icons': 4.2.5
'@primevue/themes': ^4.2.5
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^8.48.0
'@storybook/addon-docs': ^9.1.1
'@storybook/vue3': ^9.1.1

View File

@@ -43,8 +43,10 @@ import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
@@ -61,8 +63,11 @@ const didClickBuyNow = ref(false)
const loading = ref(false)
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
await authActions.purchaseCredits(editable ? customAmount.value : amount)
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}

View File

@@ -143,8 +143,8 @@ onMounted(() => {
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
lgCanvas?.selectNode(widget.node)
lgCanvas?.bringToFront(widget.node)
lgCanvas?.selectNode(widgetState.widget.node)
lgCanvas?.bringToFront(widgetState.widget.node)
}
)
})

View File

@@ -1,5 +1,5 @@
<template>
<div ref="container" class="comfy-load-3d relative h-full w-full">
<div ref="container" class="relative h-full w-full">
<LoadingOverlay ref="loadingOverlayRef" />
</div>
</template>

View File

@@ -9,7 +9,7 @@
<div ref="mainContentRef" class="relative flex-1">
<div
ref="containerRef"
class="comfy-load-3d-viewer absolute h-full w-full"
class="absolute h-full w-full"
@resize="viewer.handleResize"
/>
</div>

View File

@@ -51,7 +51,7 @@
multiple
:option-label="'display_name'"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onAddNode($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
>
<template #option="{ option }">
@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
@@ -88,6 +89,7 @@ import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
@@ -96,6 +98,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const telemetry = useTelemetry()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -118,6 +121,14 @@ const placeholder = computed(() => {
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Debounced search tracking (500ms as per implementation plan)
const debouncedTrackSearch = debounce((query: string) => {
if (query.trim()) {
telemetry?.trackNodeSearch({ query })
}
}, 500)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
@@ -128,10 +139,22 @@ const search = (query: string) => {
limit: searchLimit
})
]
// Track search queries with debounce
debouncedTrackSearch(query)
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
// Track node selection and emit addNode event
const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
telemetry?.trackNodeSearchResultSelected({
node_type: nodeDef.name,
last_query: currentQuery.value
})
emit('addNode', nodeDef)
}
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
inputElement ??= document.getElementById(inputId) as HTMLInputElement

View File

@@ -5,7 +5,11 @@ import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
LGraphGroup,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
@@ -95,8 +99,12 @@ export function useSelectionToolboxPosition(
} else {
// Fallback to LiteGraph bounds for regular nodes or non-string IDs
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
const bounds = item.getBounding()
allBounds.push([bounds[0], bounds[1], bounds[2], bounds[3]] as const)
allBounds.push([
item.pos[0],
item.pos[1] - LiteGraph.NODE_TITLE_HEIGHT,
item.size[0],
item.size[1] + LiteGraph.NODE_TITLE_HEIGHT
])
}
}
}

View File

@@ -1,9 +1,11 @@
import { refDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { debounce } from 'es-toolkit/compat'
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
@@ -212,6 +214,38 @@ export function useTemplateFiltering(
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {
useTelemetry()?.trackTemplateFilterChanged({
search_query: searchQuery.value || undefined,
selected_models: selectedModels.value,
selected_use_cases: selectedUseCases.value,
selected_licenses: selectedLicenses.value,
sort_by: sortBy.value,
filtered_count: filteredCount.value,
total_count: totalCount.value
})
}, 500)
// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedLicenses, sortBy],
() => {
// Only track if at least one filter is active (to avoid tracking initial state)
const hasActiveFilters =
searchQuery.value.trim() !== '' ||
selectedModels.value.length > 0 ||
selectedUseCases.value.length > 0 ||
selectedLicenses.value.length > 0 ||
sortBy.value !== 'default'
if (hasActiveFilters) {
debouncedTrackFilterChange()
}
},
{ deep: true }
)
return {
// State
searchQuery,

View File

@@ -1,4 +1,5 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -13,6 +14,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
}
function show() {
useTelemetry()?.trackTemplateLibraryOpened({ source: 'command' })
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,

View File

@@ -17,6 +17,7 @@ import {
matchesPropertyItem,
matchesWidgetItem,
promoteWidget,
pruneDisconnected,
widgetItemToProperty
} from '@/core/graph/subgraph/proxyWidgetUtils'
import type { WidgetItem } from '@/core/graph/subgraph/proxyWidgetUtils'
@@ -235,6 +236,7 @@ watchDebounced(
)
onMounted(() => {
setDraggableState()
if (activeNode.value) pruneDisconnected(activeNode.value)
})
onBeforeUnmount(() => {
draggableList.value?.dispose()

View File

@@ -48,9 +48,12 @@ type Overlay = Partial<IBaseWidget> & {
* on the linked widget
*/
type ProxyWidget = IBaseWidget & { _overlay: Overlay }
function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
export function isProxyWidget(w: IBaseWidget): w is ProxyWidget {
return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false
}
export function isDisconnectedWidget(w: ProxyWidget) {
return w instanceof disconnectedWidget.constructor
}
export function registerProxyWidgets(canvas: LGraphCanvas) {
//NOTE: canvasStore hasn't been initialized yet
@@ -167,7 +170,7 @@ function resolveLinkedWidget(
if (!n) return [undefined, undefined]
const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName)
//Slightly hacky. Force recursive resolution of nested widgets
if (widget instanceof disconnectedWidget.constructor && isProxyWidget(widget))
if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget))
widget.computedHeight = 20
return [n, widget]
}

View File

@@ -1,5 +1,9 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import {
isProxyWidget,
isDisconnectedWidget
} from '@/core/graph/subgraph/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -163,3 +167,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = proxyWidgets
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.properties.proxyWidgets = subgraphNode.widgets
.filter(isProxyWidget)
.filter((w) => !isDisconnectedWidget(w))
.map((w) => [w._overlay.nodeId, w._overlay.widgetName])
}

View File

@@ -84,6 +84,7 @@ class Load3d {
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.domElement.classList.add('flex', '!h-full', '!w-full')
container.appendChild(this.renderer.domElement)
this.eventManager = new EventManager()
@@ -212,7 +213,14 @@ class Load3d {
}
const contextmenuHandler = (e: MouseEvent) => {
const wasDragging = this.rightMouseMoved
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false

View File

@@ -546,6 +546,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Clean up all promoted widgets
for (const widget of this.widgets) {
if ('isProxyWidget' in widget && widget.isProxyWidget) continue
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this

View File

@@ -140,6 +140,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
displayValue,
// @ts-expect-error Prevent naming conflicts with custom nodes.
labelBaseline,
promoted,
...safeValues
} = widget

View File

@@ -1357,6 +1357,14 @@
"taskFailed": "Task failed to run.",
"cannotContinue": "Unable to continue - errors remain",
"defaultDescription": "An error occurred while running a maintenance task."
},
"unsafeMigration": {
"title": "Unsafe install location detected",
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
}
},
"missingModelsDialog": {

View File

@@ -37,6 +37,7 @@ const emit = defineEmits<{
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
@@ -62,6 +63,7 @@ const startPollingSubscriptionStatus = () => {
if (isActiveSubscription.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')
}
} catch (error) {

View File

@@ -3,18 +3,31 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateMetadata
TemplateFilterMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -123,6 +136,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
@@ -132,6 +149,20 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
const executionContext = this.getExecutionContext()
@@ -153,7 +184,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
this.trackEvent(eventName, responses)
// Apply normalization to survey responses
const normalizedResponses = responses
? normalizeSurveyResponses(responses)
: undefined
this.trackEvent(eventName, normalizedResponses)
// If this is a survey submission, also set user properties with normalized data
if (stage === 'submitted' && normalizedResponses && this.mixpanel) {
try {
this.mixpanel.people.set(normalizedResponses)
} catch (error) {
console.error('Failed to set survey user properties:', error)
}
}
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
@@ -178,6 +223,34 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
@@ -194,8 +267,28 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
const nodeMetrics = reduceAllNodes(
app.graph,
(acc, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
return {
custom_node_count: acc.custom_node_count + (isCustomNode ? 1 : 0),
api_node_count: acc.api_node_count + (isApiNode ? 1 : 0),
subgraph_count: acc.subgraph_count + (isSubgraph ? 1 : 0)
}
},
{ custom_node_count: 0, api_node_count: 0, subgraph_count: 0 }
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
@@ -218,19 +311,22 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license
template_license: englishMetadata?.license ?? template?.license,
...nodeMetrics
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename
workflow_name: activeWorkflow.filename,
...nodeMetrics
}
}
return {
is_template: false,
workflow_name: undefined
workflow_name: undefined,
...nodeMetrics
}
}
}

View File

@@ -57,6 +57,10 @@ export interface ExecutionContext {
template_models?: string[]
template_use_case?: string
template_license?: string
// Node composition metrics
custom_node_count: number
api_node_count: number
subgraph_count: number
}
/**
@@ -89,15 +93,87 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
}
/**
* Template library metadata
*/
export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
}
/**
* Page visibility metadata
*/
export interface PageVisibilityMetadata {
visibility_state: 'visible' | 'hidden'
}
/**
* Tab count metadata
*/
export interface TabCountMetadata {
tab_count: number
}
/**
* Node search metadata
*/
export interface NodeSearchMetadata {
query: string
}
/**
* Node search result selection metadata
*/
export interface NodeSearchResultMetadata {
node_type: string
last_query: string
}
/**
* Template filter tracking metadata
*/
export interface TemplateFilterMetadata {
search_query?: string
selected_models: string[]
selected_use_cases: string[]
selected_licenses: string[]
sort_by:
| 'default'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
filtered_count: number
total_count: number
}
/**
* Core telemetry provider interface
*/
export interface TelemetryProvider {
// Authentication flow events
trackAuth(metadata: AuthMetadata): void
trackUserLoggedIn(): void
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
@@ -108,6 +184,23 @@ export interface TelemetryProvider {
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
// Workflow management events
trackWorkflowImported(metadata: WorkflowImportMetadata): void
// Page visibility events
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
// Tab tracking events
trackTabCount(metadata: TabCountMetadata): void
// Node search analytics events
trackNodeSearch(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
// Template filter tracking events
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
@@ -125,11 +218,15 @@ export interface TelemetryProvider {
export const TelemetryEvents = {
// Authentication Flow
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_LOGGED_IN: 'app:user_logged_in',
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
@@ -142,6 +239,23 @@ export const TelemetryEvents = {
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -163,3 +277,11 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| PageVisibilityMetadata
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| TemplateFilterMetadata

View File

@@ -0,0 +1,683 @@
/**
* Unit tests for survey normalization utilities
* Uses real example data from migration script to verify categorization accuracy
*/
import { describe, expect, it } from 'vitest'
import {
normalizeIndustry,
normalizeUseCase,
normalizeSurveyResponses
} from '../surveyNormalization'
describe('normalizeIndustry', () => {
describe('Film / TV / Animation category', () => {
it('should categorize film and television production', () => {
expect(normalizeIndustry('Film and television production')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('film')).toBe('Film / TV / Animation')
expect(normalizeIndustry('TV production')).toBe('Film / TV / Animation')
expect(normalizeIndustry('animation studio')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('VFX artist')).toBe('Film / TV / Animation')
expect(normalizeIndustry('cinema')).toBe('Film / TV / Animation')
expect(normalizeIndustry('documentary filmmaker')).toBe(
'Film / TV / Animation'
)
})
it('should handle typos and variations', () => {
expect(normalizeIndustry('animtion')).toBe('Film / TV / Animation')
expect(normalizeIndustry('film prod')).toBe('Film / TV / Animation')
expect(normalizeIndustry('movie production')).toBe(
'Film / TV / Animation'
)
})
})
describe('Marketing / Advertising / Social Media category', () => {
it('should categorize marketing and social media', () => {
expect(normalizeIndustry('Marketing & Social Media')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('digital marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('YouTube content creation')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('TikTok marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('brand promotion')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('influencer marketing')).toBe(
'Marketing / Advertising / Social Media'
)
})
it('should handle social media variations', () => {
expect(normalizeIndustry('social content')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('content creation')).toBe(
'Marketing / Advertising / Social Media'
)
})
})
describe('Software / IT / AI category', () => {
it('should categorize software development', () => {
expect(normalizeIndustry('Software Development')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('tech startup')).toBe('Software / IT / AI')
expect(normalizeIndustry('AI research')).toBe('Software / IT / AI')
expect(normalizeIndustry('web development')).toBe('Software / IT / AI')
expect(normalizeIndustry('machine learning')).toBe('Software / IT / AI')
expect(normalizeIndustry('data science')).toBe('Software / IT / AI')
expect(normalizeIndustry('programming')).toBe('Software / IT / AI')
})
it('should handle IT variations', () => {
expect(normalizeIndustry('software engineer')).toBe('Software / IT / AI')
expect(normalizeIndustry('app developer')).toBe('Software / IT / AI')
})
it('should categorize corporate AI research', () => {
expect(normalizeIndustry('corporate AI research')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('AI research lab')).toBe('Software / IT / AI')
expect(normalizeIndustry('tech company AI research')).toBe(
'Software / IT / AI'
)
})
})
describe('Gaming / Interactive Media category', () => {
it('should categorize gaming industry', () => {
expect(normalizeIndustry('Indie Game Studio')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('game development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('VR development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('interactive media')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('metaverse')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('Unity developer')).toBe(
'Gaming / Interactive Media'
)
})
it('should handle game dev variations', () => {
expect(normalizeIndustry('game dev')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('indie games')).toBe(
'Gaming / Interactive Media'
)
})
})
describe('Architecture / Engineering / Construction category', () => {
it('should categorize architecture and construction', () => {
expect(normalizeIndustry('Architecture firm')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('construction')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('civil engineering')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('interior design')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('landscape architecture')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('real estate')).toBe(
'Architecture / Engineering / Construction'
)
})
})
describe('Fashion / Beauty / Retail category', () => {
it('should categorize fashion and beauty', () => {
expect(normalizeIndustry('Custom Jewelry Design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('fashion design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('beauty industry')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('retail store')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('cosmetics')).toBe('Fashion / Beauty / Retail')
})
})
describe('Healthcare / Medical / Life Science category', () => {
it('should categorize medical and health fields', () => {
expect(normalizeIndustry('Medical Research')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('healthcare')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('biotech')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('pharmaceutical')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('clinical research')).toBe(
'Healthcare / Medical / Life Science'
)
})
})
describe('Education / Research category', () => {
it('should categorize education and research', () => {
expect(normalizeIndustry('university research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('academic')).toBe('Education / Research')
expect(normalizeIndustry('teaching')).toBe('Education / Research')
expect(normalizeIndustry('student')).toBe('Education / Research')
expect(normalizeIndustry('professor')).toBe('Education / Research')
})
it('should categorize academic AI research', () => {
expect(normalizeIndustry('academic AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('university AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('AI research at university')).toBe(
'Education / Research'
)
})
})
describe('Fine Art / Contemporary Art category', () => {
it('should categorize art fields', () => {
expect(normalizeIndustry('fine art')).toBe('Fine Art / Contemporary Art')
expect(normalizeIndustry('contemporary artist')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('digital art')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('illustration')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('gallery')).toBe('Fine Art / Contemporary Art')
})
})
describe('Photography / Videography category', () => {
it('should categorize photography fields', () => {
expect(normalizeIndustry('photography')).toBe('Photography / Videography')
expect(normalizeIndustry('wedding photography')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('commercial photo')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('videography')).toBe('Photography / Videography')
})
})
describe('Product & Industrial Design category', () => {
it('should categorize product design', () => {
expect(normalizeIndustry('product design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('industrial design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('manufacturing')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('3d rendering')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('automotive design')).toBe(
'Product & Industrial Design'
)
})
})
describe('Music / Performing Arts category', () => {
it('should categorize music and performing arts', () => {
expect(normalizeIndustry('music production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('theater')).toBe('Music / Performing Arts')
expect(normalizeIndustry('concert production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('live events')).toBe('Music / Performing Arts')
})
})
describe('E-commerce / Print-on-Demand / Business category', () => {
it('should categorize business fields', () => {
expect(normalizeIndustry('ecommerce')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('print on demand')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('startup')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('online store')).toBe(
'E-commerce / Print-on-Demand / Business'
)
})
})
describe('Nonprofit / Government / Public Sector category', () => {
it('should categorize nonprofit and government', () => {
expect(normalizeIndustry('nonprofit')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('government agency')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('public service')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('charity')).toBe(
'Nonprofit / Government / Public Sector'
)
})
})
describe('Adult / NSFW category', () => {
it('should categorize adult content', () => {
expect(normalizeIndustry('adult entertainment')).toBe('Adult / NSFW')
expect(normalizeIndustry('NSFW content')).toBe('Adult / NSFW')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeIndustry('other')).toBe('Other / Undefined')
expect(normalizeIndustry('none')).toBe('Other / Undefined')
expect(normalizeIndustry('undefined')).toBe('Other / Undefined')
expect(normalizeIndustry('unknown')).toBe('Other / Undefined')
expect(normalizeIndustry('n/a')).toBe('Other / Undefined')
expect(normalizeIndustry('not applicable')).toBe('Other / Undefined')
expect(normalizeIndustry('-')).toBe('Other / Undefined')
expect(normalizeIndustry('')).toBe('Other / Undefined')
})
it('should handle null and invalid inputs', () => {
expect(normalizeIndustry(null as any)).toBe('Other / Undefined')
expect(normalizeIndustry(undefined as any)).toBe('Other / Undefined')
expect(normalizeIndustry(123 as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown creative fields with prefix', () => {
expect(normalizeIndustry('Unknown Creative Field')).toBe(
'Uncategorized: Unknown Creative Field'
)
expect(normalizeIndustry('Completely Novel Field')).toBe(
'Uncategorized: Completely Novel Field'
)
})
})
})
describe('normalizeUseCase', () => {
describe('Content Creation & Marketing', () => {
it('should categorize content creation', () => {
expect(normalizeUseCase('YouTube thumbnail generation')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('social media content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('marketing campaigns')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('TikTok content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('brand content creation')).toBe(
'Content Creation & Marketing'
)
})
})
describe('Art & Illustration', () => {
it('should categorize art and illustration', () => {
expect(normalizeUseCase('Creating concept art for movies')).toBe(
'Art & Illustration'
)
expect(normalizeUseCase('digital art')).toBe('Art & Illustration')
expect(normalizeUseCase('character design')).toBe('Art & Illustration')
expect(normalizeUseCase('illustration work')).toBe('Art & Illustration')
expect(normalizeUseCase('fantasy art')).toBe('Art & Illustration')
})
})
describe('Product Visualization & Design', () => {
it('should categorize product work', () => {
expect(normalizeUseCase('Product mockup creation')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('3d product rendering')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('prototype visualization')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('industrial design')).toBe(
'Product Visualization & Design'
)
})
})
describe('Gaming & Interactive Media', () => {
it('should categorize gaming use cases', () => {
expect(normalizeUseCase('Game asset generation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game development')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('VR content creation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('interactive media')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game textures')).toBe(
'Gaming & Interactive Media'
)
})
})
describe('Architecture & Construction', () => {
it('should categorize architecture use cases', () => {
expect(normalizeUseCase('Building visualization')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('architectural rendering')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('interior design mockups')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('real estate visualization')).toBe(
'Architecture & Construction'
)
})
})
describe('Photography & Image Processing', () => {
it('should categorize photography work', () => {
expect(normalizeUseCase('Product photography')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('photo editing')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('image enhancement')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('portrait photography')).toBe(
'Photography & Image Processing'
)
})
})
describe('Research & Development', () => {
it('should categorize research work', () => {
expect(normalizeUseCase('Scientific visualization')).toBe(
'Research & Development'
)
expect(normalizeUseCase('research experiments')).toBe(
'Research & Development'
)
expect(normalizeUseCase('prototype testing')).toBe(
'Research & Development'
)
expect(normalizeUseCase('innovation projects')).toBe(
'Research & Development'
)
})
})
describe('Personal & Hobby', () => {
it('should categorize personal projects', () => {
expect(normalizeUseCase('Personal art projects')).toBe('Personal & Hobby')
expect(normalizeUseCase('hobby work')).toBe('Personal & Hobby')
expect(normalizeUseCase('creative exploration')).toBe('Personal & Hobby')
expect(normalizeUseCase('fun experiments')).toBe('Personal & Hobby')
})
})
describe('Film & Video Production', () => {
it('should categorize film work', () => {
expect(normalizeUseCase('movie production')).toBe(
'Film & Video Production'
)
expect(normalizeUseCase('video editing')).toBe('Film & Video Production')
expect(normalizeUseCase('visual effects')).toBe('Film & Video Production')
expect(normalizeUseCase('storyboard creation')).toBe(
'Film & Video Production'
)
})
})
describe('Education & Training', () => {
it('should categorize educational use cases', () => {
expect(normalizeUseCase('educational content')).toBe(
'Education & Training'
)
expect(normalizeUseCase('training materials')).toBe(
'Education & Training'
)
expect(normalizeUseCase('tutorial creation')).toBe('Education & Training')
expect(normalizeUseCase('academic projects')).toBe('Education & Training')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeUseCase('other')).toBe('Other / Undefined')
expect(normalizeUseCase('none')).toBe('Other / Undefined')
expect(normalizeUseCase('undefined')).toBe('Other / Undefined')
expect(normalizeUseCase('')).toBe('Other / Undefined')
expect(normalizeUseCase(null as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown use cases with prefix', () => {
expect(normalizeUseCase('Mysterious Use Case')).toBe(
'Uncategorized: Mysterious Use Case'
)
})
})
})
describe('normalizeSurveyResponses', () => {
it('should normalize both industry and use case', () => {
const input = {
industry: 'Film and television production',
useCase: 'Creating concept art for movies',
familiarity: 'Expert'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Film and television production',
industry_normalized: 'Film / TV / Animation',
industry_raw: 'Film and television production',
useCase: 'Creating concept art for movies',
useCase_normalized: 'Art & Illustration',
useCase_raw: 'Creating concept art for movies',
familiarity: 'Expert'
})
})
it('should handle partial responses', () => {
const input = {
industry: 'Software Development',
familiarity: 'Beginner'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Software Development',
industry_normalized: 'Software / IT / AI',
industry_raw: 'Software Development',
familiarity: 'Beginner'
})
})
it('should handle empty responses', () => {
const input = {
familiarity: 'Intermediate'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
familiarity: 'Intermediate'
})
})
it('should handle uncategorized responses', () => {
const input = {
industry: 'Unknown Creative Field',
useCase: 'Mysterious Use Case'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Unknown Creative Field',
industry_normalized: 'Uncategorized: Unknown Creative Field',
industry_raw: 'Unknown Creative Field',
useCase: 'Mysterious Use Case',
useCase_normalized: 'Uncategorized: Mysterious Use Case',
useCase_raw: 'Mysterious Use Case'
})
})
describe('Migration script example data validation', () => {
it('should correctly categorize all migration script examples', () => {
const examples = [
{
input: {
industry: 'Film and television production',
useCase: 'Creating concept art for movies'
},
expected: {
industry: 'Film / TV / Animation',
useCase: 'Art & Illustration'
}
},
{
input: {
industry: 'Marketing & Social Media',
useCase: 'YouTube thumbnail generation'
},
expected: {
industry: 'Marketing / Advertising / Social Media',
useCase: 'Content Creation & Marketing'
}
},
{
input: {
industry: 'Software Development',
useCase: 'Product mockup creation'
},
expected: {
industry: 'Software / IT / AI',
useCase: 'Product Visualization & Design'
}
},
{
input: {
industry: 'Indie Game Studio',
useCase: 'Game asset generation'
},
expected: {
industry: 'Gaming / Interactive Media',
useCase: 'Gaming & Interactive Media'
}
},
{
input: {
industry: 'Architecture firm',
useCase: 'Building visualization'
},
expected: {
industry: 'Architecture / Engineering / Construction',
useCase: 'Architecture & Construction'
}
},
{
input: {
industry: 'Custom Jewelry Design',
useCase: 'Product photography'
},
expected: {
industry: 'Fashion / Beauty / Retail',
useCase: 'Photography & Image Processing'
}
},
{
input: {
industry: 'Medical Research',
useCase: 'Scientific visualization'
},
expected: {
industry: 'Healthcare / Medical / Life Science',
useCase: 'Research & Development'
}
},
{
input: {
industry: 'Unknown Creative Field',
useCase: 'Personal art projects'
},
expected: {
industry: 'Uncategorized: Unknown Creative Field',
useCase: 'Personal & Hobby'
}
}
]
examples.forEach(({ input, expected }) => {
const result = normalizeSurveyResponses(input)
expect(result.industry_normalized).toBe(expected.industry)
expect(result.useCase_normalized).toBe(expected.useCase)
})
})
})
})

View File

@@ -0,0 +1,606 @@
/**
* Survey Response Normalization Utilities
*
* Smart categorization system to normalize free-text survey responses
* into standardized categories for better analytics breakdowns.
* Uses Fuse.js for fuzzy matching against category keywords.
*/
import Fuse from 'fuse.js'
interface CategoryMapping {
name: string
keywords: string[]
userCount?: number // For reference from analysis
}
/**
* Industry category mappings based on ~9,000 user analysis
*/
const INDUSTRY_CATEGORIES: CategoryMapping[] = [
{
name: 'Film / TV / Animation',
userCount: 2885,
keywords: [
'film',
'tv',
'television',
'animation',
'animation studio',
'tv production',
'film production',
'story',
'anime',
'video',
'cinematography',
'visual effects',
'vfx',
'vfx artist',
'movie',
'cinema',
'documentary',
'documentary filmmaker',
'broadcast',
'streaming',
'production',
'director',
'filmmaker',
'post-production',
'editing'
]
},
{
name: 'Marketing / Advertising / Social Media',
userCount: 1340,
keywords: [
'marketing',
'advertising',
'youtube',
'tiktok',
'social media',
'content creation',
'influencer',
'brand',
'promotion',
'digital marketing',
'seo',
'campaigns',
'copywriting',
'growth',
'engagement'
]
},
{
name: 'Software / IT / AI',
userCount: 1100,
keywords: [
'software',
'software development',
'software engineer',
'it',
'ai',
'ai research',
'corporate ai research',
'ai research lab',
'tech company ai research',
'developer',
'app developer',
'consulting',
'tech',
'tech startup',
'programmer',
'data science',
'machine learning',
'coding',
'programming',
'web development',
'app development',
'saas'
]
},
{
name: 'Product & Industrial Design',
userCount: 1050,
keywords: [
'product design',
'industrial',
'manufacturing',
'3d rendering',
'product visualization',
'mechanical',
'automotive',
'cad',
'prototype',
'design engineering',
'invention'
]
},
{
name: 'Fine Art / Contemporary Art',
userCount: 780,
keywords: [
'fine art',
'art',
'illustration',
'contemporary',
'artist',
'painting',
'drawing',
'sculpture',
'gallery',
'canvas',
'digital art',
'mixed media',
'abstract',
'portrait'
]
},
{
name: 'Education / Research',
userCount: 640,
keywords: [
'education',
'student',
'teacher',
'research',
'university research',
'academic ai research',
'university ai research',
'ai research at university',
'learning',
'university',
'school',
'academic',
'professor',
'curriculum',
'training',
'instruction',
'pedagogy'
]
},
{
name: 'Architecture / Engineering / Construction',
userCount: 420,
keywords: [
'architecture',
'architecture firm',
'construction',
'engineering',
'civil',
'civil engineering',
'cad',
'building',
'structural',
'landscape',
'landscape architecture',
'interior design',
'real estate',
'planning',
'blueprints'
]
},
{
name: 'Gaming / Interactive Media',
userCount: 410,
keywords: [
'gaming',
'game dev',
'game development',
'indie game studio',
'vr development',
'roblox',
'interactive',
'interactive media',
'virtual world',
'vr',
'ar',
'metaverse',
'simulation',
'unity',
'unity developer',
'unreal',
'indie games'
]
},
{
name: 'Photography / Videography',
userCount: 70,
keywords: [
'photography',
'photo',
'videography',
'camera',
'image',
'portrait',
'wedding',
'commercial photo',
'stock photography',
'photojournalism',
'event photography'
]
},
{
name: 'Fashion / Beauty / Retail',
userCount: 25,
keywords: [
'fashion',
'fashion design',
'beauty',
'beauty industry',
'jewelry',
'jewelry design',
'custom jewelry design',
'retail',
'retail store',
'style',
'clothing',
'cosmetics',
'makeup',
'accessories',
'boutique'
]
},
{
name: 'Music / Performing Arts',
userCount: 25,
keywords: [
'music',
'music production',
'vj',
'dance',
'projection mapping',
'audio visual',
'concert',
'concert production',
'performance',
'theater',
'stage',
'live events'
]
},
{
name: 'Healthcare / Medical / Life Science',
userCount: 30,
keywords: [
'healthcare',
'medical',
'medical research',
'doctor',
'biotech',
'life science',
'pharmaceutical',
'clinical',
'clinical research',
'hospital',
'medicine',
'health'
]
},
{
name: 'E-commerce / Print-on-Demand / Business',
userCount: 15,
keywords: [
'ecommerce',
'e-commerce',
'print on demand',
'shop',
'business',
'commercial',
'startup',
'entrepreneur',
'sales',
'online store'
]
},
{
name: 'Nonprofit / Government / Public Sector',
userCount: 15,
keywords: [
'501c3',
'ngo',
'government',
'public service',
'policy',
'nonprofit',
'charity',
'civic',
'community',
'social impact'
]
},
{
name: 'Adult / NSFW',
userCount: 10,
keywords: [
'nsfw',
'nsfw content',
'adult',
'adult entertainment',
'erotic',
'explicit',
'xxx',
'porn'
]
}
]
/**
* Use case category mappings based on common patterns
*/
const USE_CASE_CATEGORIES: CategoryMapping[] = [
{
name: 'Content Creation & Marketing',
keywords: [
'content creation',
'social media',
'marketing',
'marketing campaigns',
'advertising',
'youtube',
'youtube thumbnail',
'youtube thumbnail generation',
'tiktok',
'instagram',
'thumbnails',
'posts',
'campaigns',
'brand content'
]
},
{
name: 'Art & Illustration',
keywords: [
'art',
'illustration',
'drawing',
'painting',
'concept art',
'creating concept art',
'character design',
'digital art',
'fantasy art',
'portraits'
]
},
{
name: 'Product Visualization & Design',
keywords: [
'product',
'product mockup',
'product mockup creation',
'visualization',
'prototype visualization',
'design',
'prototype',
'mockup',
'3d rendering',
'industrial design',
'product photos'
]
},
{
name: 'Film & Video Production',
keywords: [
'film',
'video',
'video editing',
'movie',
'movie production',
'animation',
'vfx',
'visual effects',
'storyboard',
'storyboard creation',
'cinematography',
'post production'
]
},
{
name: 'Gaming & Interactive Media',
keywords: [
'game',
'gaming',
'game asset generation',
'game assets',
'game development',
'game textures',
'interactive',
'vr',
'vr content creation',
'ar',
'virtual',
'simulation',
'metaverse',
'textures'
]
},
{
name: 'Architecture & Construction',
keywords: [
'architecture',
'architectural rendering',
'building',
'building visualization',
'construction',
'interior design',
'interior design mockups',
'landscape',
'real estate',
'real estate visualization',
'floor plans',
'renderings'
]
},
{
name: 'Education & Training',
keywords: [
'education',
'educational',
'educational content',
'training',
'training materials',
'learning',
'teaching',
'tutorial',
'tutorial creation',
'course',
'academic',
'academic projects',
'instructional',
'workshops'
]
},
{
name: 'Research & Development',
keywords: [
'research',
'research experiments',
'development',
'experiment',
'prototype',
'prototype testing',
'testing',
'analysis',
'study',
'innovation',
'innovation projects',
'r&d',
'scientific visualization'
]
},
{
name: 'Personal & Hobby',
keywords: [
'personal',
'personal art projects',
'hobby',
'hobby work',
'fun',
'fun experiments',
'experiment',
'learning',
'curiosity',
'explore',
'creative',
'creative exploration',
'side project'
]
},
{
name: 'Photography & Image Processing',
keywords: [
'photography',
'product photography',
'portrait photography',
'photo',
'photo editing',
'image',
'image enhancement',
'portrait',
'editing',
'enhancement',
'restoration',
'photo manipulation'
]
}
]
/**
* Fuse.js configuration for category matching
*/
const FUSE_OPTIONS = {
keys: ['keywords'],
threshold: 0.53, // Higher = more lenient matching
minMatchCharLength: 5,
includeScore: true,
includeMatches: true,
ignoreLocation: true,
findAllMatches: true
}
/**
* Create Fuse instances for category matching
*/
const industryFuse = new Fuse(INDUSTRY_CATEGORIES, FUSE_OPTIONS)
const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
/**
* Normalize industry responses using Fuse.js fuzzy search
*/
export function normalizeIndustry(rawIndustry: string): string {
if (!rawIndustry || typeof rawIndustry !== 'string') {
return 'Other / Undefined'
}
const industry = rawIndustry.toLowerCase().trim()
// Handle common undefined responses
if (
industry.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = industryFuse.search(rawIndustry)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawIndustry}`
}
/**
* Normalize use case responses using Fuse.js fuzzy search
*/
export function normalizeUseCase(rawUseCase: string): string {
if (!rawUseCase || typeof rawUseCase !== 'string') {
return 'Other / Undefined'
}
const useCase = rawUseCase.toLowerCase().trim()
// Handle common undefined responses
if (
useCase.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = useCaseFuse.search(rawUseCase)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawUseCase}`
}
/**
* Apply normalization to survey responses
* Creates both normalized and raw versions of responses
*/
export function normalizeSurveyResponses(responses: {
industry?: string
useCase?: string
[key: string]: any
}) {
const normalized = { ...responses }
// Normalize industry
if (responses.industry) {
normalized.industry_normalized = normalizeIndustry(responses.industry)
normalized.industry_raw = responses.industry
}
// Normalize use case
if (responses.useCase) {
normalized.useCase_normalized = normalizeUseCase(responses.useCase)
normalized.useCase_raw = responses.useCase
}
return normalized
}

View File

@@ -18,6 +18,7 @@ import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -1271,6 +1272,14 @@ export class ComfyApp {
'afterConfigureGraph',
missingNodeTypes
)
// Track workflow import with missing node information
useTelemetry()?.trackWorkflowImported({
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
)
})
await useWorkflowService().afterLoadNewGraph(
workflow,
this.graph.serialize() as unknown as ComfyWorkflowJSON

View File

@@ -61,7 +61,7 @@ function getSubgraphsFromInstanceIds(
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] {
): Subgraph[] | undefined {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
@@ -69,7 +69,10 @@ function getSubgraphsFromInstanceIds(
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
if (!subgraph) {
console.warn(`Subgraph not found: ${currentPart}`)
return undefined
}
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
@@ -80,7 +83,9 @@ function getSubgraphsFromInstanceIds(
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
function executionIdToNodeLocatorId(
nodeId: string | number
): NodeLocatorId | undefined {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
@@ -92,6 +97,7 @@ function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
if (!subgraphs) return undefined
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}

View File

@@ -463,6 +463,27 @@ export function traverseNodesDepthFirst<T = void>(
}
}
/**
* Reduces all nodes in a graph hierarchy to a single value using a reducer function.
* Single-pass traversal for efficient aggregation.
*
* @param graph - The root graph to traverse
* @param reducer - Function that reduces each node into the accumulator
* @param initialValue - The initial accumulator value
* @returns The final reduced value
*/
export function reduceAllNodes<T>(
graph: LGraph | Subgraph,
reducer: (accumulator: T, node: LGraphNode) => T,
initialValue: T
): T {
let result = initialValue
forEachNode(graph, (node) => {
result = reducer(result, node)
})
return result
}
/**
* Options for collectFromNodes function
*/

View File

@@ -48,7 +48,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
@@ -59,6 +61,7 @@ import { useKeybindingService } from '@/services/keybindingService'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useModelStore } from '@/stores/modelStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -87,6 +90,13 @@ const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const telemetry = useTelemetry()
const firebaseAuthStore = useFirebaseAuthStore()
let hasTrackedLogin = false
let visibilityListener: (() => void) | null = null
let tabCountInterval: number | null = null
let tabCountChannel: BroadcastChannel | null = null
watch(
() => colorPaletteStore.completedActivePalette,
(newTheme) => {
@@ -250,6 +260,22 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
// Clean up page visibility listener
if (visibilityListener) {
document.removeEventListener('visibilitychange', visibilityListener)
visibilityListener = null
}
// Clean up tab count tracking
if (tabCountInterval) {
window.clearInterval(tabCountInterval)
tabCountInterval = null
}
if (tabCountChannel) {
tabCountChannel.close()
tabCountChannel = null
}
})
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
@@ -268,6 +294,61 @@ void nextTick(() => {
const onGraphReady = () => {
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
}
// Set up page visibility tracking (cloud only)
if (isCloud && telemetry && !visibilityListener) {
visibilityListener = () => {
telemetry.trackPageVisibilityChanged({
visibility_state: document.visibilityState as 'visible' | 'hidden'
})
}
document.addEventListener('visibilitychange', visibilityListener)
}
// Set up tab count tracking (cloud only)
if (isCloud && telemetry && !tabCountInterval) {
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
const activeTabs = new Map<string, number>()
const currentTabId = crypto.randomUUID()
// Listen for heartbeats from other tabs
tabCountChannel.onmessage = (event) => {
if (
event.data.type === 'heartbeat' &&
event.data.tabId !== currentTabId
) {
activeTabs.set(event.data.tabId, Date.now())
}
}
// 30-second heartbeat interval
tabCountInterval = window.setInterval(() => {
const now = Date.now()
// Clean up stale tabs (no heartbeat for 45 seconds)
activeTabs.forEach((lastHeartbeat, tabId) => {
if (now - lastHeartbeat > 45000) {
activeTabs.delete(tabId)
}
})
// Broadcast our heartbeat
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
// Track tab count (include current tab)
const tabCount = activeTabs.size + 1
telemetry.trackTabCount({ tab_count: tabCount })
}, 30000)
// Send initial heartbeat
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()

View File

@@ -156,14 +156,11 @@ describe('useExecutionStore - NodeLocatorId conversions', () => {
expect(result).toBe('123')
})
it('should return null when conversion fails', () => {
it('should return undefined when conversion fails', () => {
// Mock app.graph.getNodeById to return null (node not found)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
// This should throw an error as the node is not found
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
'Subgraph not found: 999'
)
expect(store.executionIdToNodeLocatorId('999:456')).toBe(undefined)
})
})

View File

@@ -13,6 +13,11 @@ import {
createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {

View File

@@ -1,3 +1,4 @@
import { sentryVitePlugin } from '@sentry/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { config as dotenvConfig } from 'dotenv'
@@ -289,6 +290,27 @@ export default defineConfig({
template: 'treemap' // or 'sunburst', 'network'
})
]
: []),
// Sentry sourcemap upload plugin
// Only runs during cloud production builds when all Sentry env vars are present
// Requires: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT env vars
...(DISTRIBUTION === 'cloud' &&
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
!IS_DEV
? [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
// Delete source maps after upload to prevent public access
filesToDeleteAfterUpload: ['**/*.map']
}
})
]
: [])
],