feat: add dynamic Fuse.js options loading for template filtering (#7822)

## Summary

PRD:
https://www.notion.so/comfy-org/Implement-Move-search-config-to-templates-repo-for-template-owner-adjustability-2c76d73d365081ad81c4ed33332eda09

Move search config to templates repo for template owner adjustability

## Changes

- **What**: 
- Made `fuseOptions` reactive in `useTemplateFiltering` composable to
support dynamic updates
- Added `getFuseOptions()` API method to fetch Fuse.js configuration
from `/templates/fuse_options.json`
- Added `loadFuseOptions()` function to `useTemplateFiltering` that
fetches and applies server-provided options
- Removed unused `templateFuse` computed property from
`workflowTemplatesStore`
- Added comprehensive unit tests covering success, null response, error
handling, and Fuse instance recreation scenarios

- **Breaking**: None

- **Dependencies**: None (uses existing `fuse.js` and `axios`
dependencies)

## Review Focus

- Verify that the API endpoint path `/templates/fuse_options.json` is
correct and accessible
- Confirm that the reactive `fuseOptions` properly triggers Fuse
instance recreation when updated
- Check that error handling gracefully falls back to default options
when server fetch fails
- Ensure the watch on `fuseOptions` is necessary or can be removed
(currently just recreates Fuse via computed)
- Review test coverage to ensure all edge cases are handled

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7822-feat-add-dynamic-Fuse-js-options-loading-for-template-filtering-2db6d73d365081828103d8ee70844b2e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Yourz
2026-01-11 14:24:43 +08:00
committed by GitHub
parent 2d5d18c020
commit dcfa53fd7d
5 changed files with 183 additions and 40 deletions

View File

@@ -563,7 +563,8 @@ const {
availableRunsOn, availableRunsOn,
filteredCount, filteredCount,
totalCount, totalCount,
resetFilters resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates) } = useTemplateFiltering(navigationFilteredTemplates)
/** /**
@@ -815,10 +816,10 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState // Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState( const { isLoading } = useAsyncState(
async () => { async () => {
// Run all operations in parallel for better performance
await Promise.all([ await Promise.all([
loadTemplates(), loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates() workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
]) ])
return true return true
}, },

View File

@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template' 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 } = const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering') await import('@/composables/useTemplateFiltering')
@@ -49,6 +57,7 @@ describe('useTemplateFiltering', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.clearAllMocks() vi.clearAllMocks()
mockGetFuseOptions.mockResolvedValue(null)
}) })
afterEach(() => { afterEach(() => {
@@ -272,4 +281,118 @@ describe('useTemplateFiltering', () => {
'beta-pro' 'beta-pro'
]) ])
}) })
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([
{
name: 'test-template',
description: 'Test template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const customFuseOptions: IFuseOptions<TemplateInfo> = {
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<TemplateInfo[]>([
{
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<TemplateInfo[]>([
{
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<TemplateInfo[]>([
{
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)
})
})
}) })

View File

@@ -1,5 +1,6 @@
import { refDebounced, watchDebounced } from '@vueuse/core' import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import type { Ref } 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 type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateRankingStore } from '@/stores/templateRankingStore' import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat' import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
// Fuse.js configuration for fuzzy search
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
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( export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[] templates: Ref<TemplateInfo[]> | TemplateInfo[]
@@ -35,26 +51,14 @@ export function useTemplateFiltering(
| 'model-size-low-to-high' | 'model-size-low-to-high'
>(settingStore.get('Comfy.Templates.SortBy')) >(settingStore.get('Comfy.Templates.SortBy'))
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
const templatesArray = computed(() => { const templatesArray = computed(() => {
const templateData = 'value' in templates ? templates.value : templates const templateData = 'value' in templates ? templates.value : templates
return Array.isArray(templateData) ? templateData : [] return Array.isArray(templateData) ? templateData : []
}) })
// Fuse.js configuration for fuzzy search const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions.value))
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 availableModels = computed(() => { const availableModels = computed(() => {
const modelSet = new Set<string>() const modelSet = new Set<string>()
@@ -272,6 +276,13 @@ export function useTemplateFiltering(
}) })
}, 500) }, 500)
const loadFuseOptions = async () => {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
}
// Watch for filter changes and track them // Watch for filter changes and track them
watch( watch(
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy], [searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
@@ -344,6 +355,7 @@ export function useTemplateFiltering(
resetFilters, resetFilters,
removeModelFilter, removeModelFilter,
removeUseCaseFilter, removeUseCaseFilter,
removeRunsOnFilter removeRunsOnFilter,
loadFuseOptions
} }
} }

View File

@@ -1,4 +1,3 @@
import Fuse from 'fuse.js'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue' import { computed, ref, shallowRef } from 'vue'
@@ -250,24 +249,6 @@ export const useWorkflowTemplatesStore = defineStore(
return filteredTemplates 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 * Filter templates by category ID using stored filter mappings
*/ */
@@ -548,7 +529,6 @@ export const useWorkflowTemplatesStore = defineStore(
groupedTemplates, groupedTemplates,
navGroupedTemplates, navGroupedTemplates,
enhancedTemplates, enhancedTemplates,
templateFuse,
filterTemplatesByCategory, filterTemplatesByCategory,
isLoaded, isLoaded,
loadWorkflowTemplates, loadWorkflowTemplates,

View File

@@ -10,7 +10,10 @@ import type {
} from '@/platform/assets/schemas/assetSchema' } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore' 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 { import type {
ComfyApiWorkflow, ComfyApiWorkflow,
ComfyWorkflowJSON, ComfyWorkflowJSON,
@@ -51,6 +54,7 @@ import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes' import type { AuthHeader } from '@/types/authTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification' import type { NodeExecutionId } from '@/types/nodeIdentification'
import { fetchHistory } from '@/platform/remote/comfyui/history' import { fetchHistory } from '@/platform/remote/comfyui/history'
import type { IFuseOptions } from 'fuse.js'
interface QueuePromptRequestBody { interface QueuePromptRequestBody {
client_id: string client_id: string
@@ -1269,6 +1273,29 @@ 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<IFuseOptions<TemplateInfo> | null> {
try {
const res = await axios.get(
this.fileURL('/templates/fuse_options.json'),
{
headers: {
'Content-Type': 'application/json'
}
}
)
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : null
} catch (error) {
console.error('Error loading fuse options:', error)
return null
}
}
/** /**
* Gets the custom nodes i18n data from the server. * Gets the custom nodes i18n data from the server.
* *