mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
11 Commits
synap5e/fe
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f2c51ba4f | ||
|
|
5597a51769 | ||
|
|
e0f8c6ef7c | ||
|
|
64aa788f88 | ||
|
|
fb2b408c40 | ||
|
|
b64259e7af | ||
|
|
8ba0dc5fc4 | ||
|
|
ff7a7d8e65 | ||
|
|
0c783ba895 | ||
|
|
123a635cfe | ||
|
|
9df6350385 |
@@ -92,6 +92,7 @@ import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
@@ -126,6 +127,8 @@ const placeholder = computed(() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
useSearchQueryTracking('node_modal', currentQuery, suggestions)
|
||||
|
||||
// Debounced search tracking (500ms as per implementation plan)
|
||||
const debouncedTrackSearch = debounce((query: string) => {
|
||||
if (query.trim()) {
|
||||
|
||||
@@ -121,6 +121,7 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -344,6 +345,8 @@ const hoveredNodeDef = computed(
|
||||
() => displayedResults.value[selectedIndex.value] ?? null
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
|
||||
|
||||
watch(
|
||||
hoveredNodeDef,
|
||||
(newVal) => {
|
||||
|
||||
@@ -166,6 +166,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -212,6 +213,7 @@ const filteredWorkflows = computed(() => {
|
||||
workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
)
|
||||
})
|
||||
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
|
||||
@@ -190,6 +190,7 @@ import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -337,6 +338,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
|
||||
@@ -190,6 +190,7 @@ import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -289,6 +290,8 @@ const activeNodes = computed(() =>
|
||||
: filteredNodeDefs.value
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
|
||||
const hasNoMatches = computed(
|
||||
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
|
||||
)
|
||||
|
||||
@@ -53,10 +53,15 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn()
|
||||
trackTemplateFilterChanged: vi.fn(),
|
||||
trackSearchQuery: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
@@ -307,6 +308,7 @@ export function useTemplateFiltering(
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
const totalCount = computed(() => visibleTemplates.value.length)
|
||||
useSearchQueryTracking('templates', searchQuery, filteredTemplates)
|
||||
|
||||
// Template filter tracking (debounced to avoid excessive events)
|
||||
const debouncedTrackFilterChange = debounce(() => {
|
||||
|
||||
@@ -95,6 +95,7 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
ISettingGroup,
|
||||
@@ -205,6 +206,8 @@ function onNavItemClick(id: string) {
|
||||
|
||||
const searchResults = computed<ISettingGroup[]>(() => getSearchResults(null))
|
||||
|
||||
useSearchQueryTracking('settings', searchQuery, searchResults)
|
||||
|
||||
// Scroll to and highlight the target setting once the correct category renders.
|
||||
if (scrollToSettingId) {
|
||||
const stopScrollWatch = watch(
|
||||
|
||||
48
src/platform/telemetry/TelemetryRegistry.test.ts
Normal file
48
src/platform/telemetry/TelemetryRegistry.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TelemetryRegistry } from './TelemetryRegistry'
|
||||
import type { TelemetryProvider } from './types'
|
||||
|
||||
describe('TelemetryRegistry', () => {
|
||||
it('dispatches trackSearchQuery to every registered provider', () => {
|
||||
const a: TelemetryProvider = { trackSearchQuery: vi.fn() }
|
||||
const b: TelemetryProvider = { trackSearchQuery: vi.fn() }
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(a)
|
||||
registry.registerProvider(b)
|
||||
|
||||
registry.trackSearchQuery({
|
||||
surface: 'templates',
|
||||
query: 'flux',
|
||||
query_length: 4,
|
||||
result_count: 3,
|
||||
has_results: true
|
||||
})
|
||||
|
||||
const payload = {
|
||||
surface: 'templates',
|
||||
query: 'flux',
|
||||
query_length: 4,
|
||||
result_count: 3,
|
||||
has_results: true
|
||||
}
|
||||
expect(a.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
expect(b.trackSearchQuery).toHaveBeenCalledExactlyOnceWith(payload)
|
||||
})
|
||||
|
||||
it('skips providers that do not implement trackSearchQuery', () => {
|
||||
const empty: TelemetryProvider = {}
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(empty)
|
||||
|
||||
expect(() =>
|
||||
registry.trackSearchQuery({
|
||||
surface: 'settings',
|
||||
query: 'theme',
|
||||
query_length: 5,
|
||||
result_count: 0,
|
||||
has_results: false
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
@@ -199,6 +200,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
trackSearchQuery(metadata: SearchQueryMetadata): void {
|
||||
this.dispatch((provider) => provider.trackSearchQuery?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
|
||||
}
|
||||
|
||||
@@ -172,6 +172,30 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures search queries with surface, query, length, and result count', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackSearchQuery({
|
||||
surface: 'node_sidebar',
|
||||
query: 'sampler',
|
||||
query_length: 7,
|
||||
result_count: 3,
|
||||
has_results: true
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.SEARCH_QUERY,
|
||||
{
|
||||
surface: 'node_sidebar',
|
||||
query: 'sampler',
|
||||
query_length: 7,
|
||||
result_count: 3,
|
||||
has_results: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('sets first_auth_at on new-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
SearchQueryMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
@@ -458,6 +459,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackSearchQuery(metadata: SearchQueryMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SEARCH_QUERY, metadata)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope, Ref } from 'vue'
|
||||
import { effectScope, ref } from 'vue'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
trackSearchQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({ trackSearchQuery: hoisted.trackSearchQuery })
|
||||
}))
|
||||
|
||||
import { useSearchQueryTracking } from './useSearchQueryTracking'
|
||||
|
||||
const DEBOUNCE_FLUSH_MS = 600
|
||||
const flush = () => new Promise((r) => setTimeout(r, DEBOUNCE_FLUSH_MS))
|
||||
|
||||
describe('useSearchQueryTracking', () => {
|
||||
const scopes: EffectScope[] = []
|
||||
|
||||
function track(
|
||||
query: Ref<string>,
|
||||
results: Ref<{ length: number }>
|
||||
): EffectScope {
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
useSearchQueryTracking('node_sidebar', query, results)
|
||||
})
|
||||
scopes.push(scope)
|
||||
return scope
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.trackSearchQuery.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scopes.forEach((s) => s.stop())
|
||||
scopes.length = 0
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fires with surface, trimmed query, length, and result_count', async () => {
|
||||
const query = ref('')
|
||||
const results = ref<string[]>(['a', 'b', 'c'])
|
||||
track(query, results)
|
||||
query.value = ' hello '
|
||||
await flush()
|
||||
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
|
||||
surface: 'node_sidebar',
|
||||
query: 'hello',
|
||||
query_length: 5,
|
||||
result_count: 3,
|
||||
has_results: true
|
||||
})
|
||||
})
|
||||
|
||||
it('sets has_results false when results are empty', async () => {
|
||||
const query = ref('')
|
||||
const results = ref<string[]>([])
|
||||
track(query, results)
|
||||
query.value = 'nothingmatches'
|
||||
await flush()
|
||||
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
|
||||
surface: 'node_sidebar',
|
||||
query: 'nothingmatches',
|
||||
query_length: 14,
|
||||
result_count: 0,
|
||||
has_results: false
|
||||
})
|
||||
})
|
||||
|
||||
it('skips empty queries', async () => {
|
||||
const query = ref('seed')
|
||||
const results = ref<string[]>(['a', 'b'])
|
||||
track(query, results)
|
||||
query.value = ' '
|
||||
await flush()
|
||||
expect(hoisted.trackSearchQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending debounced call when the scope is disposed', async () => {
|
||||
const query = ref('')
|
||||
const results = ref<string[]>(['a', 'b'])
|
||||
const scope = track(query, results)
|
||||
query.value = 'hello'
|
||||
scope.stop()
|
||||
await flush()
|
||||
expect(hoisted.trackSearchQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('truncates query to 100 chars while preserving original length', async () => {
|
||||
const query = ref('')
|
||||
const results = ref<string[]>(['a'])
|
||||
track(query, results)
|
||||
const long = 'x'.repeat(250)
|
||||
query.value = long
|
||||
await flush()
|
||||
expect(hoisted.trackSearchQuery).toHaveBeenCalledExactlyOnceWith({
|
||||
surface: 'node_sidebar',
|
||||
query: 'x'.repeat(100),
|
||||
query_length: 250,
|
||||
result_count: 1,
|
||||
has_results: true
|
||||
})
|
||||
})
|
||||
})
|
||||
44
src/platform/telemetry/searchQuery/useSearchQueryTracking.ts
Normal file
44
src/platform/telemetry/searchQuery/useSearchQueryTracking.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { onScopeDispose, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SearchSurface } from '@/platform/telemetry/types'
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
const MAX_QUERY_CHARS = 100
|
||||
|
||||
/**
|
||||
* Fires `app:search_query` for the given surface, debounced 500ms.
|
||||
* Empty queries are skipped. `results` is read at fire time and only its
|
||||
* `.length` is observed, so callers can pass any reactive array (filtered
|
||||
* suggestions, displayed results, etc.). The pending debounced call is
|
||||
* cancelled on scope dispose so a user who types-and-closes within the
|
||||
* window doesn't emit a stale event.
|
||||
*
|
||||
* The captured `query` is truncated to MAX_QUERY_CHARS to cap PII
|
||||
* exposure (mirrors GTM provider's sanitizeProperties cap). `query_length`
|
||||
* remains the full pre-truncation length so we keep the distributional
|
||||
* signal.
|
||||
*/
|
||||
export function useSearchQueryTracking(
|
||||
surface: SearchSurface,
|
||||
query: Ref<string>,
|
||||
results: Ref<{ length: number }>
|
||||
): void {
|
||||
const fire = debounce((value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
const count = results.value.length
|
||||
useTelemetry()?.trackSearchQuery({
|
||||
surface,
|
||||
query: trimmed.slice(0, MAX_QUERY_CHARS),
|
||||
query_length: trimmed.length,
|
||||
result_count: count,
|
||||
has_results: count > 0
|
||||
})
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
watch(query, fire)
|
||||
onScopeDispose(() => fire.cancel())
|
||||
}
|
||||
@@ -246,6 +246,25 @@ export interface NodeSearchMetadata {
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Search query metadata. One event per debounced query change across
|
||||
* each search surface.
|
||||
*/
|
||||
export type SearchSurface =
|
||||
| 'node_modal'
|
||||
| 'node_sidebar'
|
||||
| 'apps'
|
||||
| 'templates'
|
||||
| 'settings'
|
||||
|
||||
export interface SearchQueryMetadata {
|
||||
surface: SearchSurface
|
||||
query: string
|
||||
query_length: number
|
||||
result_count: number
|
||||
has_results: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Node added metadata. `source` indicates how the user initiated the add.
|
||||
* Bulk additions during workflow load are excluded — workflow_imported
|
||||
@@ -469,6 +488,9 @@ export interface TelemetryProvider {
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Search query analytics
|
||||
trackSearchQuery?(metadata: SearchQueryMetadata): void
|
||||
|
||||
// Node-added-to-canvas analytics
|
||||
trackNodeAdded?(metadata: NodeAddedMetadata): void
|
||||
|
||||
@@ -558,6 +580,7 @@ export const TelemetryEvents = {
|
||||
// Node Search Analytics
|
||||
NODE_SEARCH: 'app:node_search',
|
||||
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
|
||||
SEARCH_QUERY: 'app:search_query',
|
||||
NODE_ADDED: 'app:node_added_to_workflow',
|
||||
|
||||
// Template Filter Analytics
|
||||
@@ -616,6 +639,7 @@ export type TelemetryEventProperties =
|
||||
| TabCountMetadata
|
||||
| NodeSearchMetadata
|
||||
| NodeSearchResultMetadata
|
||||
| SearchQueryMetadata
|
||||
| TemplateFilterMetadata
|
||||
| SettingChangedMetadata
|
||||
| UiButtonClickMetadata
|
||||
|
||||
Reference in New Issue
Block a user