mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
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:
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user