mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 23:50:08 +00:00
implement filters in search providers
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -25,23 +25,78 @@
|
||||
@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">
|
||||
<template v-for="filterOption in filterOptions" :key="filterOption.id">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-muted">{{ filterOption.label }}:</span>
|
||||
<Dropdown
|
||||
v-if="filterOption.type === 'single-select'"
|
||||
:model-value="selectedFilters[filterOption.id] as string"
|
||||
:options="filterOption.options || []"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Any"
|
||||
:show-clear="true"
|
||||
class="min-w-[6rem] border-none bg-transparent shadow-none"
|
||||
:pt="{
|
||||
input: { class: 'py-0 px-1 border-none' },
|
||||
trigger: { class: 'hidden' },
|
||||
panel: { class: 'shadow-md' },
|
||||
item: { class: 'py-2 px-3 text-sm' }
|
||||
}"
|
||||
@update:model-value="
|
||||
$event
|
||||
? (selectedFilters[filterOption.id] = $event)
|
||||
: delete selectedFilters[filterOption.id]
|
||||
"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-else-if="filterOption.type === 'multi-select'"
|
||||
:model-value="selectedFilters[filterOption.id] as string[]"
|
||||
:options="filterOption.options || []"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
display="chip"
|
||||
class="min-w-[6rem] border-none bg-transparent shadow-none"
|
||||
:pt="{
|
||||
input: { class: 'py-0 px-1 border-none' },
|
||||
trigger: { class: 'hidden' },
|
||||
panel: { class: 'shadow-md' },
|
||||
item: { class: 'py-2 px-3 text-sm' },
|
||||
label: { class: 'py-0 px-1 text-sm' },
|
||||
header: { class: 'p-2' },
|
||||
filterInput: { class: 'text-sm' },
|
||||
emptyMessage: { class: 'text-sm text-muted p-3' }
|
||||
}"
|
||||
@update:model-value="
|
||||
$event?.length > 0
|
||||
? (selectedFilters[filterOption.id] = $event)
|
||||
: delete selectedFilters[filterOption.id]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,31 +107,36 @@ import { stubTrue } from 'lodash'
|
||||
import AutoComplete, {
|
||||
AutoCompleteOptionSelectEvent
|
||||
} from 'primevue/autocomplete'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
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,7 +152,7 @@ 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') }
|
||||
]
|
||||
|
||||
@@ -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,29 @@ export function useRegistrySearch(
|
||||
return getSortableFields()
|
||||
})
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
return getFilterableFields()
|
||||
})
|
||||
|
||||
// Initialize filters with default values when they become available
|
||||
const filterOptionsInitialized = ref(false)
|
||||
watch(
|
||||
filterOptions,
|
||||
(newOptions) => {
|
||||
if (!filterOptionsInitialized.value && newOptions.length > 0) {
|
||||
const defaultFilters: ActiveFilters = {}
|
||||
for (const option of newOptions) {
|
||||
if (option.defaultValue !== undefined) {
|
||||
defaultFilters[option.id] = option.defaultValue
|
||||
}
|
||||
}
|
||||
activeFilters.value = { ...activeFilters.value, ...defaultFilters }
|
||||
filterOptionsInitialized.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
pageNumber,
|
||||
@@ -113,6 +137,8 @@ export function useRegistrySearch(
|
||||
suggestions,
|
||||
searchResults,
|
||||
sortOptions,
|
||||
activeFilters,
|
||||
filterOptions,
|
||||
clearCache: clearSearchCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||
@@ -21,6 +23,11 @@ interface ProviderState {
|
||||
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
|
||||
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
|
||||
|
||||
// Global state shared across all uses of the gateway
|
||||
const providers: ProviderState[] = []
|
||||
const activeProviderIndex = ref(0)
|
||||
let isInitialized = false
|
||||
|
||||
/**
|
||||
* API Gateway for registry search providers with circuit breaker pattern.
|
||||
* Acts as a single entry point that routes search requests to appropriate providers
|
||||
@@ -32,27 +39,29 @@ 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
|
||||
// Initialize providers only once
|
||||
if (!isInitialized) {
|
||||
// Initialize providers in priority order
|
||||
try {
|
||||
providers.push({
|
||||
provider: useAlgoliaSearchProvider(),
|
||||
name: 'Algolia',
|
||||
isHealthy: true,
|
||||
consecutiveFailures: 0
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize Algolia provider:', error)
|
||||
}
|
||||
|
||||
// Initialize providers in priority order
|
||||
try {
|
||||
providers.push({
|
||||
provider: useAlgoliaSearchProvider(),
|
||||
name: 'Algolia',
|
||||
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
|
||||
})
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
// TODO: Add an "offline" provider that operates on a local cache of the registry.
|
||||
|
||||
@@ -109,7 +118,7 @@ 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 = providers[activeProviderIndex.value]
|
||||
if (currentProvider && isCircuitClosed(currentProvider)) {
|
||||
return currentProvider.provider
|
||||
}
|
||||
@@ -118,7 +127,7 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
for (let i = 0; i < providers.length; i++) {
|
||||
const providerState = providers[i]
|
||||
if (isCircuitClosed(providerState)) {
|
||||
activeProviderIndex = i
|
||||
activeProviderIndex.value = i
|
||||
return providerState.provider
|
||||
}
|
||||
}
|
||||
@@ -131,8 +140,8 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
* Move to the next provider if available.
|
||||
*/
|
||||
const updateActiveProviderOnFailure = () => {
|
||||
if (activeProviderIndex < providers.length - 1) {
|
||||
activeProviderIndex++
|
||||
if (activeProviderIndex.value < providers.length - 1) {
|
||||
activeProviderIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +158,14 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
for (let attempts = 0; attempts < providers.length; attempts++) {
|
||||
try {
|
||||
const provider = getActiveProvider()
|
||||
const providerState = providers[activeProviderIndex]
|
||||
const providerState = providers[activeProviderIndex.value]
|
||||
|
||||
const result = await provider.searchPacks(query, params)
|
||||
recordSuccess(providerState)
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
const providerState = providers[activeProviderIndex]
|
||||
const providerState = providers[activeProviderIndex.value]
|
||||
recordFailure(providerState, lastError)
|
||||
console.warn(
|
||||
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
|
||||
@@ -218,10 +227,22 @@ export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||
return getActiveProvider().getSortableFields()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filterable fields for the active provider.
|
||||
* This is now a computed property that will react to provider changes.
|
||||
*/
|
||||
const getFilterableFields = () => {
|
||||
// Access activeProviderIndex.value to establish reactivity
|
||||
void activeProviderIndex.value
|
||||
const provider = getActiveProvider()
|
||||
return provider.getFilterableFields()
|
||||
}
|
||||
|
||||
return {
|
||||
searchPacks,
|
||||
clearSearchCache,
|
||||
getSortValue,
|
||||
getSortableFields
|
||||
getSortableFields,
|
||||
getFilterableFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,72 @@ 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) {
|
||||
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 +116,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 +133,111 @@ 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
|
||||
for (const device of stats.devices) {
|
||||
const deviceType = device.type.toLowerCase()
|
||||
if (deviceType.includes('cuda')) return 'cuda'
|
||||
if (deviceType.includes('mps')) return 'mps'
|
||||
if (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 +245,7 @@ export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
|
||||
searchPacks,
|
||||
clearSearchCache,
|
||||
getSortValue,
|
||||
getSortableFields
|
||||
getSortableFields,
|
||||
getFilterableFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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: 'multi-select' | 'single-select' | 'boolean'
|
||||
options?: FilterOption[]
|
||||
defaultValue?: string | string[] | boolean
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export type ActiveFilters = Record<string, string | string[] | boolean>
|
||||
|
||||
export interface SearchPacksResult {
|
||||
nodePacks: RegistryNodePack[]
|
||||
querySuggestions: QuerySuggestion[]
|
||||
@@ -46,4 +63,10 @@ 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[]
|
||||
}
|
||||
|
||||
@@ -30,14 +30,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 +65,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 +104,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 +143,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 +180,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 +223,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 +233,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 +253,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 +284,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 +322,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 +355,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 +365,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 +391,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 +433,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)
|
||||
|
||||
Reference in New Issue
Block a user