Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
f08b9aa9eb [fix] Add validation and improve device detection in registry search
- Add sortField validation to prevent malformed API requests
- Improve GPU device type detection with additional vendor patterns (nvidia, amd, apple)
- Prevent potential injection via sort parameters
2025-07-04 14:11:09 -07:00
bymyself
2dbb237a83 add pinia to unit tests 2025-06-15 22:33:43 -07:00
bymyself
85f0aca045 put search provider global state in store 2025-06-15 20:39:46 -07:00
bymyself
6eed618a94 Simplify filter UI
Simplify filter UI by using SearchFilterDropdown for all filters

- Remove complex template logic with Dropdown and MultiSelect components
- Use SearchFilterDropdown component for all filters (consistent with sort/mode dropdowns)
- Remove multi-select support to simplify implementation
- Add placeholder and clear functionality to SearchFilterDropdown
2025-06-15 20:39:46 -07:00
bymyself
3f036fcf51 implement filters in search providers 2025-06-15 19:56:15 -07:00
13 changed files with 648 additions and 144 deletions

View File

@@ -0,0 +1,190 @@
{
"id": "cffcce2d-a13c-4a5f-929b-82f274bacc36",
"revision": 0,
"last_node_id": 14,
"last_link_id": 14,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [
-361.02374267578125,
-40.05255126953125
],
"size": [
274.080078125,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
11
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"ComfyUI_00137_.png",
"image"
]
},
{
"id": 12,
"type": "ImageBatch",
"pos": [
146.92184448242188,
104.8472671508789
],
"size": [
140,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 11
},
{
"name": "image2",
"type": "IMAGE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
14
]
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 14,
"type": "SaveAnimatedPNG",
"pos": [
457.4212646484375,
39.56276321411133
],
"size": [
270,
368
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 14
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI",
6,
4
]
},
{
"id": 11,
"type": "LoadImage",
"pos": [
-360.4931640625,
326.1943664550781
],
"size": [
274.080078125,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
12
]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"ComfyUI_00153_.png",
"image"
]
}
],
"links": [
[
11,
10,
0,
12,
0,
"IMAGE"
],
[
12,
11,
0,
12,
1,
"IMAGE"
],
[
14,
12,
0,
14,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.129559245649766,
"offset": [
768.6140137916129,
203.6152852376302
]
},
"frontendVersion": "1.22.2"
},
"version": 0.4
}

View File

@@ -30,9 +30,11 @@
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
v-model:activeFilters="activeFilters"
:search-results="searchResults"
:suggestions="suggestions"
:sort-options="sortOptions"
:filter-options="filterOptions"
/>
<div class="flex-1 overflow-auto">
<div
@@ -121,6 +123,7 @@ import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
@@ -132,6 +135,7 @@ const { initialTab } = defineProps<{
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const systemStatsStore = useSystemStatsStore()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
@@ -181,7 +185,9 @@ const {
searchMode,
sortField,
suggestions,
sortOptions
sortOptions,
activeFilters,
filterOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
@@ -464,8 +470,13 @@ whenever(selectedNodePack, async () => {
})
let gridContainer: HTMLElement | null = null
onMounted(() => {
onMounted(async () => {
gridContainer = document.getElementById('results-grid')
// Fetch system stats if not already loaded
if (!systemStatsStore.systemStats && !systemStatsStore.isLoading) {
await systemStatsStore.fetchSystemStats()
}
})
watch(searchQuery, () => {
gridContainer ??= document.getElementById('results-grid')

View File

@@ -25,23 +25,35 @@
@option-select="onOptionSelect"
/>
</div>
<div class="flex mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:modelValue="searchMode"
:options="filterOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between mt-3 text-sm">
<div class="flex gap-6 ml-1">
<SearchFilterDropdown
v-model:modelValue="searchMode"
:options="searchModeOptions"
:label="$t('g.filter')"
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
<div class="flex items-center gap-4 mr-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
</small>
</div>
</div>
<div class="flex items-center gap-4 ml-6">
<small v-if="hasResults" class="text-color-secondary">
{{ $t('g.resultsCount', { count: searchResults?.length || 0 }) }}
</small>
<!-- Add search refinement dropdowns if provider supports them -->
<div v-if="filterOptions?.length" class="flex gap-3 ml-1 text-sm">
<SearchFilterDropdown
v-for="filterOption in filterOptions"
:key="filterOption.id"
v-model:modelValue="selectedFilters[filterOption.id]"
:options="availableFilterOptions(filterOption)"
:label="filterOption.label"
/>
</div>
</div>
</div>
@@ -56,27 +68,30 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { type SearchOption } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
ActiveFilters,
QuerySuggestion,
SearchFilter,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults, sortOptions } = defineProps<{
const { searchResults, sortOptions, filterOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
filterOptions?: SearchFilter[]
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
default: 'total_install'
})
const selectedFilters = defineModel<ActiveFilters>('activeFilters', {
default: () => ({})
})
const { t } = useI18n()
@@ -92,11 +107,22 @@ const availableSortOptions = computed<SearchOption<string>[]>(() => {
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
const searchModeOptions: SearchOption<SearchMode>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// Convert filter options to SearchOption format for SearchFilterDropdown
const availableFilterOptions = (
filter: SearchFilter
): SearchOption<string>[] => {
if (!filter.options) return []
return filter.options.map((option) => ({
id: option.value,
label: option.label
}))
}
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query

View File

@@ -6,6 +6,7 @@
:options="options"
option-label="label"
option-value="id"
placeholder="Any"
class="min-w-[6rem] border-none bg-transparent shadow-none"
:pt="{
input: { class: 'py-0 px-1 border-none' },

View File

@@ -1,18 +1,20 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
import type {
ActiveFilters,
QuerySuggestion,
SearchMode
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
const DEFAULT_SORT_FIELD = 'total_install' // Downloads field in the database
/**
* Composable for managing UI state of Comfy Node Registry search.
@@ -40,6 +42,7 @@ export function useRegistrySearch(
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const activeFilters = ref<ActiveFilters>({})
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
@@ -47,43 +50,40 @@ export function useRegistrySearch(
const searchGateway = useRegistrySearchGateway()
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const {
searchPacks,
clearSearchCache,
getSortableFields,
getFilterableFields
} = searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const sortDirection = fieldConfig?.direction || 'desc'
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.value,
{
pageSize: pageSize.value,
pageNumber: pageNumber.value,
restrictSearchableAttributes: searchAttributes.value
restrictSearchableAttributes: searchAttributes.value,
filters: activeFilters.value,
sortField: sortField.value,
sortDirection
}
)
let sortedPacks = nodePacks
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
searchResults.value = searchResults.value.concat(nodePacks)
} else {
searchResults.value = sortedPacks
searchResults.value = nodePacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -93,6 +93,7 @@ export function useRegistrySearch(
const onPageChange = () => updateSearchResults({ append: true })
watch([sortField, searchMode], onQueryChange)
watch(activeFilters, onQueryChange, { deep: true })
watch(pageNumber, onPageChange)
watchDebounced(searchQuery, onQueryChange, {
debounce: SEARCH_DEBOUNCE_TIME,
@@ -103,6 +104,10 @@ export function useRegistrySearch(
return getSortableFields()
})
const filterOptions = computed(() => {
return getFilterableFields()
})
return {
isLoading,
pageNumber,
@@ -113,6 +118,8 @@ export function useRegistrySearch(
suggestions,
searchResults,
sortOptions,
activeFilters,
filterOptions,
clearCache: clearSearchCache
}
}

View File

@@ -1,23 +1,16 @@
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
ProviderState,
SearchPacksResult
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
interface ProviderState {
provider: NodePackSearchProvider
name: string
isHealthy: boolean
lastError?: Error
lastAttempt?: Date
consecutiveFailures: number
}
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
@@ -32,27 +25,31 @@ const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
* - Automatic failover: Cascades through providers on failure
*/
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
const providers: ProviderState[] = []
let activeProviderIndex = 0
const store = useComfyRegistryStore()
// Initialize providers in priority order
try {
providers.push({
provider: useAlgoliaSearchProvider(),
name: 'Algolia',
// Initialize providers only once
if (!store.isSearchGatewayInitialized) {
// Initialize providers in priority order
try {
store.searchProviders.push({
provider: useAlgoliaSearchProvider(),
name: 'Algolia',
isHealthy: true,
consecutiveFailures: 0
})
} catch (error) {
console.warn('Failed to initialize Algolia provider:', error)
}
store.searchProviders.push({
provider: useComfyRegistrySearchProvider(),
name: 'ComfyRegistry',
isHealthy: true,
consecutiveFailures: 0
})
} catch (error) {
console.warn('Failed to initialize Algolia provider:', error)
}
providers.push({
provider: useComfyRegistrySearchProvider(),
name: 'ComfyRegistry',
isHealthy: true,
consecutiveFailures: 0
})
store.isSearchGatewayInitialized = true
}
// TODO: Add an "offline" provider that operates on a local cache of the registry.
@@ -109,16 +106,17 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
*/
const getActiveProvider = (): NodePackSearchProvider => {
// First, try to use the current active provider if it's healthy
const currentProvider = providers[activeProviderIndex]
const currentProvider =
store.searchProviders[store.activeSearchProviderIndex]
if (currentProvider && isCircuitClosed(currentProvider)) {
return currentProvider.provider
}
// Otherwise, find the first healthy provider
for (let i = 0; i < providers.length; i++) {
const providerState = providers[i]
for (let i = 0; i < store.searchProviders.length; i++) {
const providerState = store.searchProviders[i]
if (isCircuitClosed(providerState)) {
activeProviderIndex = i
store.activeSearchProviderIndex = i
return providerState.provider
}
}
@@ -131,8 +129,8 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
* Move to the next provider if available.
*/
const updateActiveProviderOnFailure = () => {
if (activeProviderIndex < providers.length - 1) {
activeProviderIndex++
if (store.activeSearchProviderIndex < store.searchProviders.length - 1) {
store.activeSearchProviderIndex++
}
}
@@ -146,17 +144,23 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
let lastError: Error | null = null
// Start with the current active provider
for (let attempts = 0; attempts < providers.length; attempts++) {
for (
let attempts = 0;
attempts < store.searchProviders.length;
attempts++
) {
try {
const provider = getActiveProvider()
const providerState = providers[activeProviderIndex]
const providerState =
store.searchProviders[store.activeSearchProviderIndex]
const result = await provider.searchPacks(query, params)
recordSuccess(providerState)
return result
} catch (error) {
lastError = error as Error
const providerState = providers[activeProviderIndex]
const providerState =
store.searchProviders[store.activeSearchProviderIndex]
recordFailure(providerState, lastError)
console.warn(
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
@@ -178,7 +182,7 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
* Clear the search cache for all providers that implement it.
*/
const clearSearchCache = () => {
for (const providerState of providers) {
for (const providerState of store.searchProviders) {
try {
providerState.provider.clearSearchCache()
} catch (error) {
@@ -218,10 +222,19 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
return getActiveProvider().getSortableFields()
}
/**
* Get the filterable fields for the active provider.
*/
const getFilterableFields = () => {
const provider = getActiveProvider()
return provider.getFilterableFields()
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
getSortableFields,
getFilterableFields
}
}

View File

@@ -20,6 +20,7 @@ import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchFilter,
SearchPacksResult,
SortableField
} from '@/types/searchServiceTypes'
@@ -105,7 +106,14 @@ export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber } = params
const rest = omit(params, ['pageSize', 'pageNumber'])
const rest = omit(params, [
'pageSize',
'pageNumber',
'sortField',
'sortDirection',
'filters'
])
// TODO:'filters', `sortField` and `sortDirection` need to be mapped to the appropriate Algolia syntax later
const requests: SearchQuery[] = [
{
@@ -223,10 +231,16 @@ export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
]
}
const getFilterableFields = (): SearchFilter[] => {
// Algolia provider doesn't support filters yet, returning empty array
return []
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
getSortableFields,
getFilterableFields
}
}

View File

@@ -1,13 +1,17 @@
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { components, operations } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchFilter,
SearchPacksResult,
SortableField
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
type ListNodesParams = operations['listAllNodes']['parameters']['query']
type SearchNodesParams = operations['searchNodes']['parameters']['query']
/**
* Search provider for the Comfy Registry.
@@ -15,6 +19,7 @@ type RegistryNodePack = components['schemas']['Node']
*/
export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
const registryStore = useComfyRegistryStore()
const systemStatsStore = useSystemStatsStore()
/**
* Search for node packs using the Comfy Registry API.
@@ -23,20 +28,76 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber, restrictSearchableAttributes } = params
const {
pageSize,
pageNumber,
restrictSearchableAttributes,
filters,
sortField,
sortDirection
} = params
// Determine search mode based on searchable attributes
const isNodeSearch = restrictSearchableAttributes?.includes('comfy_nodes')
const hasSearchQuery = query && query.trim().length > 0
const searchParams = {
search: isNodeSearch ? undefined : query,
comfy_node_search: isNodeSearch ? query : undefined,
limit: pageSize,
offset: pageNumber * pageSize
let searchResult: { nodes?: RegistryNodePack[] } | null = null
if (hasSearchQuery) {
// Use /nodes/search endpoint when there's a search query
const searchParams: SearchNodesParams = {
search: isNodeSearch ? undefined : query,
comfy_node_search: isNodeSearch ? query : undefined,
limit: pageSize,
page: pageNumber + 1 // API uses 1-based page numbers
}
// Apply filters that are supported by search endpoint
if (filters) {
if (typeof filters.supported_os === 'string') {
searchParams.supported_os = filters.supported_os
}
// Map from our unified filter name to the search endpoint's parameter name
if (typeof filters.supported_accelerator === 'string') {
searchParams.supported_accelerator = filters.supported_accelerator
}
}
searchResult = await registryStore.search.call(searchParams)
} else {
// Use /nodes endpoint when there's no search query (supports more parameters)
const listParams: ListNodesParams = {
limit: pageSize,
page: pageNumber + 1 // API uses 1-based page numbers
}
// Apply filters that are supported by list endpoint
if (filters) {
if (typeof filters.supported_os === 'string') {
listParams.supported_os = filters.supported_os
}
if (typeof filters.supported_accelerator === 'string') {
listParams.supported_accelerator = filters.supported_accelerator
}
if (typeof filters.timestamp === 'string') {
listParams.timestamp = filters.timestamp
}
}
// Apply sort if provided (only supported by list endpoint)
if (sortField) {
// Validate sort field to prevent malformed API requests
if (!/^[a-zA-Z_]+$/.test(sortField)) {
throw new Error(`Invalid sort field: ${sortField}`)
}
const sortParam =
sortDirection === 'desc' ? `${sortField};desc` : sortField
listParams.sort = [sortParam]
}
searchResult = await registryStore.listAllPacks.call(listParams)
}
const searchResult = await registryStore.search.call(searchParams)
if (!searchResult || !searchResult.nodes) {
return {
nodePacks: [],
@@ -59,13 +120,13 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
sortField: string
): string | number => {
switch (sortField) {
case 'downloads':
case 'total_install':
return pack.downloads ?? 0
case 'name':
return pack.name ?? ''
case 'publisher':
case 'publisher_name':
return pack.publisher?.name ?? ''
case 'updated':
case 'last_updated':
return pack.latest_version?.createdAt
? new Date(pack.latest_version.createdAt).getTime()
: 0
@@ -76,10 +137,114 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
const getSortableFields = (): SortableField[] => {
return [
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
{ id: 'total_install', label: 'Downloads', direction: 'desc' },
{ id: 'name', label: 'Name', direction: 'asc' },
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
{ id: 'updated', label: 'Updated', direction: 'desc' }
{ id: 'publisher_name', label: 'Publisher', direction: 'asc' },
{ id: 'last_updated', label: 'Updated', direction: 'desc' }
]
}
/**
* Map system OS to filter value
*/
const getDefaultOSFilter = (): string | undefined => {
const stats = systemStatsStore.systemStats
if (!stats?.system?.os) return undefined
const osLower = stats.system.os.toLowerCase()
if (osLower.includes('windows')) return 'windows'
if (osLower.includes('darwin') || osLower.includes('mac')) return 'macos'
if (osLower.includes('linux')) return 'linux'
return undefined
}
/**
* Map system GPU to filter value
*/
const getDefaultAcceleratorFilter = (): string | undefined => {
const stats = systemStatsStore.systemStats
if (!stats?.devices || stats.devices.length === 0) return undefined
// Look for the first GPU device - check for additional patterns
for (const device of stats.devices) {
const deviceType = device.type.toLowerCase()
if (deviceType.includes('nvidia') || deviceType.includes('cuda'))
return 'cuda'
if (deviceType.includes('apple') || deviceType.includes('mps'))
return 'mps'
if (deviceType.includes('amd') || deviceType.includes('rocm'))
return 'rocm'
if (deviceType.includes('directml')) return 'directml'
}
return undefined
}
const getFilterableFields = (): SearchFilter[] => {
return [
{
id: 'supported_os',
label: 'Operating System',
type: 'single-select',
options: [
{ value: 'windows', label: 'Windows' },
{ value: 'macos', label: 'macOS' },
{ value: 'linux', label: 'Linux' }
],
defaultValue: getDefaultOSFilter()
},
{
// Note: search endpoint uses singular, list endpoint uses plural
id: 'supported_accelerator',
label: 'GPU Support',
type: 'single-select',
options: [
{ value: 'cuda', label: 'CUDA (NVIDIA)' },
{ value: 'directml', label: 'DirectML' },
{ value: 'rocm', label: 'ROCm (AMD)' },
{ value: 'mps', label: 'Metal (Apple)' }
],
defaultValue: getDefaultAcceleratorFilter()
},
{
// Note: timestamp filter is only available on the list endpoint (no search query)
id: 'timestamp',
label: 'Updated Since',
type: 'single-select',
options: [
{
value: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
label: 'Last 24 hours'
},
{
value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
label: 'Last week'
},
{
value: new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000
).toISOString(),
label: 'Last month'
},
{
value: new Date(
Date.now() - 90 * 24 * 60 * 60 * 1000
).toISOString(),
label: 'Last 3 months'
},
{
value: new Date(
Date.now() - 180 * 24 * 60 * 60 * 1000
).toISOString(),
label: 'Last 6 months'
},
{
value: new Date(
Date.now() - 365 * 24 * 60 * 60 * 1000
).toISOString(),
label: 'Last year'
}
]
}
]
}
@@ -87,6 +252,7 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
getSortableFields,
getFilterableFields
}
}

View File

@@ -1,10 +1,12 @@
import QuickLRU from '@alloc/quick-lru'
import { partition } from 'lodash'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import type { components, operations } from '@/types/comfyRegistryTypes'
import type { ProviderState } from '@/types/searchServiceTypes'
const PACK_LIST_CACHE_SIZE = 20
const PACK_BY_ID_CACHE_SIZE = 64
@@ -34,6 +36,11 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
maxSize: PACK_BY_ID_CACHE_SIZE
})
// Search gateway state
const searchProviders = ref<ProviderState[]>([])
const activeSearchProviderIndex = ref(0)
const isSearchGatewayInitialized = ref(false)
/**
* Get a list of all node packs from the registry
*/
@@ -137,6 +144,11 @@ export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
cancelRequests,
isLoading: registryService.isLoading,
error: registryService.error
error: registryService.error,
// Search gateway state
searchProviders,
activeSearchProviderIndex,
isSearchGatewayInitialized
}
})

View File

@@ -1,7 +1,4 @@
import type {
BaseSearchParamsWithoutQuery,
Hit
} from 'algoliasearch/dist/lite/browser'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import type { components } from '@/types/comfyRegistryTypes'
@@ -86,8 +83,11 @@ export interface NodesIndexSuggestion {
/**
* Parameters for searching the Algolia index.
*/
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
export interface SearchNodePacksParams {
pageSize: number
pageNumber: number
restrictSearchableAttributes?: SearchAttribute[]
filters?: Record<string, string | string[] | boolean>
sortField?: string
sortDirection?: 'asc' | 'desc'
}

View File

@@ -7,11 +7,28 @@ type RegistryNodePack = components['schemas']['Node']
* Search mode for filtering results
*/
export type SearchMode = 'nodes' | 'packs'
export type QuerySuggestion = {
query: string
popularity: number
}
export interface SearchFilter {
id: string
label: string
type: 'single-select' | 'boolean'
options?: FilterOption[]
defaultValue?: string | boolean
}
export interface FilterOption {
value: string
label: string
icon?: string
}
export type ActiveFilters = Record<string, string | boolean>
export interface SearchPacksResult {
nodePacks: RegistryNodePack[]
querySuggestions: QuerySuggestion[]
@@ -46,4 +63,22 @@ export interface NodePackSearchProvider {
* Get the list of sortable fields supported by this provider
*/
getSortableFields(): SortableField[]
/**
* Get the list of filterable fields supported by this provider
* Providers that don't support filters should return an empty array
*/
getFilterableFields(): SearchFilter[]
}
/**
* State of a search provider
*/
export interface ProviderState {
provider: NodePackSearchProvider
name: string
isHealthy: boolean
lastError?: Error
lastAttempt?: Date
consecutiveFailures: number
}

View File

@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
@@ -14,6 +15,7 @@ describe('useRegistrySearchGateway', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
})
@@ -30,14 +32,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -63,7 +67,8 @@ describe('useRegistrySearchGateway', () => {
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
@@ -101,14 +106,16 @@ describe('useRegistrySearchGateway', () => {
.mockRejectedValueOnce(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -138,14 +145,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -173,14 +182,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -214,7 +225,8 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
@@ -223,7 +235,8 @@ describe('useRegistrySearchGateway', () => {
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -242,14 +255,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -271,14 +286,16 @@ describe('useRegistrySearchGateway', () => {
throw new Error('Cache clear failed')
}),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -307,14 +324,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
getSortableFields: vi.fn().mockReturnValue(algoliaFields),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -338,7 +357,8 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
getSortableFields: vi.fn().mockReturnValue(algoliaFields),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
@@ -347,7 +367,8 @@ describe('useRegistrySearchGateway', () => {
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(registryFields)
getSortableFields: vi.fn().mockReturnValue(registryFields),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -372,14 +393,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn().mockReturnValue(100),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
@@ -412,14 +435,16 @@ describe('useRegistrySearchGateway', () => {
searchPacks: vi.fn().mockRejectedValue(algoliaError),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
getSortableFields: vi.fn().mockReturnValue([]),
getFilterableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)

View File

@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
@@ -14,6 +15,7 @@ describe('useComfyRegistrySearchProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup store mock
vi.mocked(useComfyRegistryStore).mockReturnValue({
@@ -45,7 +47,7 @@ describe('useComfyRegistrySearchProvider', () => {
search: 'test',
comfy_node_search: undefined,
limit: 10,
offset: 0
page: 1
})
expect(result.nodePacks).toEqual(mockResults.nodes)
expect(result.querySuggestions).toEqual([])
@@ -68,7 +70,7 @@ describe('useComfyRegistrySearchProvider', () => {
search: undefined,
comfy_node_search: 'LoadImage',
limit: 20,
offset: 20
page: 2
})
expect(result.nodePacks).toEqual(mockResults.nodes)
})
@@ -136,7 +138,7 @@ describe('useComfyRegistrySearchProvider', () => {
it('should return download count for downloads field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
expect(provider.getSortValue(testPack, 'total_install')).toBe(100)
})
it('should return pack name for name field', () => {
@@ -146,22 +148,24 @@ describe('useComfyRegistrySearchProvider', () => {
it('should return publisher name for publisher field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
expect(provider.getSortValue(testPack, 'publisher_name')).toBe(
'Publisher One'
)
})
it('should return timestamp for updated field', () => {
const provider = useComfyRegistrySearchProvider()
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
expect(provider.getSortValue(testPack, 'last_updated')).toBe(timestamp)
})
it('should handle missing values gracefully', () => {
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
expect(provider.getSortValue(incompletePack, 'total_install')).toBe(0)
expect(provider.getSortValue(incompletePack, 'publisher_name')).toBe('')
expect(provider.getSortValue(incompletePack, 'last_updated')).toBe(0)
})
it('should return 0 for unknown sort fields', () => {
@@ -176,10 +180,10 @@ describe('useComfyRegistrySearchProvider', () => {
const fields = provider.getSortableFields()
expect(fields).toEqual([
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
{ id: 'total_install', label: 'Downloads', direction: 'desc' },
{ id: 'name', label: 'Name', direction: 'asc' },
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
{ id: 'updated', label: 'Updated', direction: 'desc' }
{ id: 'publisher_name', label: 'Publisher', direction: 'asc' },
{ id: 'last_updated', label: 'Updated', direction: 'desc' }
])
})
})