diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index 7c76e971d8..aeb98971ba 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -563,7 +563,8 @@ const { availableRunsOn, filteredCount, totalCount, - resetFilters + resetFilters, + loadFuseOptions } = useTemplateFiltering(navigationFilteredTemplates) /** @@ -815,10 +816,10 @@ const pageTitle = computed(() => { // Initialize templates loading with useAsyncState const { isLoading } = useAsyncState( async () => { - // Run all operations in parallel for better performance await Promise.all([ loadTemplates(), - workflowTemplatesStore.loadWorkflowTemplates() + workflowTemplatesStore.loadWorkflowTemplates(), + loadFuseOptions() ]) return true }, diff --git a/src/composables/useTemplateFiltering.test.ts b/src/composables/useTemplateFiltering.test.ts index 5f30e5ec1b..f6e617cb72 100644 --- a/src/composables/useTemplateFiltering.test.ts +++ b/src/composables/useTemplateFiltering.test.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' +import type { IFuseOptions } from 'fuse.js' import type { TemplateInfo } from '@/platform/workflow/templates/types/template' @@ -42,6 +43,13 @@ vi.mock('@/platform/telemetry', () => ({ })) })) +const mockGetFuseOptions = vi.hoisted(() => vi.fn()) +vi.mock('@/scripts/api', () => ({ + api: { + getFuseOptions: mockGetFuseOptions + } +})) + const { useTemplateFiltering } = await import('@/composables/useTemplateFiltering') @@ -49,6 +57,7 @@ describe('useTemplateFiltering', () => { beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() + mockGetFuseOptions.mockResolvedValue(null) }) afterEach(() => { @@ -272,4 +281,118 @@ describe('useTemplateFiltering', () => { 'beta-pro' ]) }) + + describe('loadFuseOptions', () => { + it('updates fuseOptions when getFuseOptions returns valid options', async () => { + const templates = ref([ + { + name: 'test-template', + description: 'Test template', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const customFuseOptions: IFuseOptions = { + keys: [ + { name: 'name', weight: 0.5 }, + { name: 'description', weight: 0.5 } + ], + threshold: 0.4, + includeScore: true + } + + mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions) + + const { loadFuseOptions, filteredTemplates } = + useTemplateFiltering(templates) + + await loadFuseOptions() + + expect(mockGetFuseOptions).toHaveBeenCalledTimes(1) + expect(filteredTemplates.value).toBeDefined() + }) + + it('does not update fuseOptions when getFuseOptions returns null', async () => { + const templates = ref([ + { + name: 'test-template', + description: 'Test template', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + mockGetFuseOptions.mockResolvedValueOnce(null) + + const { loadFuseOptions, filteredTemplates } = + useTemplateFiltering(templates) + + const initialResults = filteredTemplates.value + + await loadFuseOptions() + + expect(mockGetFuseOptions).toHaveBeenCalledTimes(1) + expect(filteredTemplates.value).toEqual(initialResults) + }) + + it('handles errors when getFuseOptions fails', async () => { + const templates = ref([ + { + name: 'test-template', + description: 'Test template', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error')) + + const { loadFuseOptions, filteredTemplates } = + useTemplateFiltering(templates) + + const initialResults = filteredTemplates.value + + await expect(loadFuseOptions()).rejects.toThrow('Network error') + expect(filteredTemplates.value).toEqual(initialResults) + }) + + it('recreates Fuse instance when fuseOptions change', async () => { + const templates = ref([ + { + name: 'searchable-template', + description: 'This is a searchable template', + mediaType: 'image', + mediaSubtype: 'png' + }, + { + name: 'another-template', + description: 'Another template', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const { loadFuseOptions, searchQuery, filteredTemplates } = + useTemplateFiltering(templates) + + const customFuseOptions = { + keys: [{ name: 'name', weight: 1.0 }], + threshold: 0.2, + includeScore: true, + includeMatches: true + } + + mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions) + + await loadFuseOptions() + await nextTick() + + searchQuery.value = 'searchable' + await nextTick() + + expect(filteredTemplates.value.length).toBeGreaterThan(0) + expect(mockGetFuseOptions).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index fdb892e07b..9dcdeffcc0 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -1,5 +1,6 @@ import { refDebounced, watchDebounced } from '@vueuse/core' import Fuse from 'fuse.js' +import type { IFuseOptions } from 'fuse.js' import { computed, ref, watch } from 'vue' import type { Ref } from 'vue' @@ -8,6 +9,21 @@ import { useTelemetry } from '@/platform/telemetry' import type { TemplateInfo } from '@/platform/workflow/templates/types/template' import { useTemplateRankingStore } from '@/stores/templateRankingStore' import { debounce } from 'es-toolkit/compat' +import { api } from '@/scripts/api' + +// Fuse.js configuration for fuzzy search +const defaultFuseOptions: IFuseOptions = { + keys: [ + { name: 'name', weight: 0.3 }, + { name: 'title', weight: 0.3 }, + { name: 'description', weight: 0.1 }, + { name: 'tags', weight: 0.2 }, + { name: 'models', weight: 0.3 } + ], + threshold: 0.33, + includeScore: true, + includeMatches: true +} export function useTemplateFiltering( templates: Ref | TemplateInfo[] @@ -35,26 +51,14 @@ export function useTemplateFiltering( | 'model-size-low-to-high' >(settingStore.get('Comfy.Templates.SortBy')) + const fuseOptions = ref>(defaultFuseOptions) + const templatesArray = computed(() => { const templateData = 'value' in templates ? templates.value : templates return Array.isArray(templateData) ? templateData : [] }) - // Fuse.js configuration for fuzzy search - const fuseOptions = { - keys: [ - { name: 'name', weight: 0.3 }, - { name: 'title', weight: 0.3 }, - { name: 'description', weight: 0.1 }, - { name: 'tags', weight: 0.2 }, - { name: 'models', weight: 0.3 } - ], - threshold: 0.33, - includeScore: true, - includeMatches: true - } - - const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions)) + const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions.value)) const availableModels = computed(() => { const modelSet = new Set() @@ -272,6 +276,13 @@ export function useTemplateFiltering( }) }, 500) + const loadFuseOptions = async () => { + const fetchedOptions = await api.getFuseOptions() + if (fetchedOptions) { + fuseOptions.value = fetchedOptions + } + } + // Watch for filter changes and track them watch( [searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy], @@ -344,6 +355,7 @@ export function useTemplateFiltering( resetFilters, removeModelFilter, removeUseCaseFilter, - removeRunsOnFilter + removeRunsOnFilter, + loadFuseOptions } } diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index 8ce653d4da..f96872f79e 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -1,4 +1,3 @@ -import Fuse from 'fuse.js' import { defineStore } from 'pinia' import { computed, ref, shallowRef } from 'vue' @@ -250,24 +249,6 @@ export const useWorkflowTemplatesStore = defineStore( return filteredTemplates }) - /** - * Fuse.js instance for advanced template searching and filtering - */ - const templateFuse = computed(() => { - const fuseOptions = { - keys: [ - { name: 'searchableText', weight: 0.4 }, - { name: 'title', weight: 0.3 }, - { name: 'name', weight: 0.2 }, - { name: 'tags', weight: 0.1 } - ], - threshold: 0.3, - includeScore: true - } - - return new Fuse(enhancedTemplates.value, fuseOptions) - }) - /** * Filter templates by category ID using stored filter mappings */ @@ -548,7 +529,6 @@ export const useWorkflowTemplatesStore = defineStore( groupedTemplates, navGroupedTemplates, enhancedTemplates, - templateFuse, filterTemplatesByCategory, isLoaded, loadWorkflowTemplates, diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 3061f74bdb..8da4364320 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -10,7 +10,10 @@ import type { } from '@/platform/assets/schemas/assetSchema' import { isCloud } from '@/platform/distribution/types' import { useToastStore } from '@/platform/updates/common/toastStore' -import { type WorkflowTemplates } from '@/platform/workflow/templates/types/template' +import { + type TemplateInfo, + type WorkflowTemplates +} from '@/platform/workflow/templates/types/template' import type { ComfyApiWorkflow, ComfyWorkflowJSON, @@ -51,6 +54,7 @@ import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { AuthHeader } from '@/types/authTypes' import type { NodeExecutionId } from '@/types/nodeIdentification' import { fetchHistory } from '@/platform/remote/comfyui/history' +import type { IFuseOptions } from 'fuse.js' interface QueuePromptRequestBody { client_id: string @@ -1269,6 +1273,28 @@ export class ComfyApi extends EventTarget { } } + /** + * Gets the Fuse options from the server. + * + * @returns The Fuse options, or null if not found or invalid + */ + async getFuseOptions(): Promise | null> { + try { + const res = await axios.get( + this.fileURL('/templates/fuse_options.json'), + { + headers: { + 'Content-Type': 'application/json' + } + } + ) + return res?.data ?? null + } catch (error) { + console.error('Error loading fuse options:', error) + return null + } + } + /** * Gets the custom nodes i18n data from the server. *