Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,8 @@ const {
availableRunsOn,
filteredCount,
totalCount,
resetFilters
resetFilters,
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
// Convert between string array and object array for MultiSelect component
Expand Down Expand Up @@ -753,7 +754,8 @@ const { isLoading } = useAsyncState(
// Run both operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
workflowTemplatesStore.loadWorkflowTemplates(),
loadFuseOptions()
])
return true
},
Expand Down
123 changes: 123 additions & 0 deletions src/composables/useTemplateFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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'

Expand Down Expand Up @@ -29,12 +30,20 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))

const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
getFuseOptions: mockGetFuseOptions
}
}))

const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')

describe('useTemplateFiltering', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetFuseOptions.mockResolvedValue(null)
})

afterEach(() => {
Expand Down Expand Up @@ -258,4 +267,118 @@ describe('useTemplateFiltering', () => {
'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)
})
Comment on lines +325 to +344
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the implementation of loadFuseOptions in useTemplateFiltering.ts
rg -nA 20 'loadFuseOptions' src/composables/useTemplateFiltering.ts | head -60

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 904


🏁 Script executed:

#!/bin/bash
# Also check how loadFuseOptions is called in WorkflowTemplateSelectorDialog.vue
rg -nB 5 -A 5 'loadFuseOptions' src/components/WorkflowTemplateSelectorDialog.vue

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Check if there's error handling setup around getFuseOptions calls
rg -nB 3 -A 10 'getFuseOptions' src/composables/useTemplateFiltering.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 538


🏁 Script executed:

#!/bin/bash
# Search for imports/usages of useTemplateFiltering to find the component
rg -l 'useTemplateFiltering' src/components/

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Look for loadFuseOptions usage across the codebase
rg -nB 5 -A 5 'loadFuseOptions\(\)' src/

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1078


🏁 Script executed:

#!/bin/bash
# Check the complete context around the Promise.all call for error handling
rg -nB 20 -A 10 'Promise.all' src/components/custom/widget/WorkflowTemplateSelectorDialog.vue | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1047


Test correctly expects error propagation - but implementation lacks error handling.

The test is correct: loadFuseOptions() has no try-catch and will propagate errors from api.getFuseOptions(). However, the implementation violates the coding guideline requiring "Implement proper error handling in code" for TypeScript/Vue files.

The function should either:

  1. Catch errors and fall back gracefully (with appropriate error messaging), or
  2. Re-throw with context for the caller to handle

Currently, the function propagates errors without context, and callers in WorkflowTemplateSelectorDialog.vue rely on useAsyncState for error handling rather than explicit try-catch. While functional due to the wrapper, this lacks explicit error handling as required by guidelines.


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)
})
Comment on lines +346 to +382
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Test assertion could be strengthened to verify Fuse options actually affect search behavior.

The test at line 380 only asserts filteredTemplates.value.length > 0, which would pass even if the new Fuse options aren't applied. A more robust test would capture search results before loading options, load new options with different threshold/weights, search again, and verify results differ.

🔎 Stronger test approach
  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)

+   // Capture baseline results with default options
+   searchQuery.value = 'searchable'
+   await nextTick()
+   const resultsBeforeOptionsLoad = [...filteredTemplates.value]

    const customFuseOptions = {
-     keys: [{ name: 'name', weight: 1.0 }],
-     threshold: 0.2,
+     keys: [{ name: 'description', weight: 1.0 }], // Different key priority
+     threshold: 0.8, // More lenient threshold
      includeScore: true,
      includeMatches: true
    }

    mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)

    await loadFuseOptions()
    await nextTick()

-   searchQuery.value = 'searchable'
+   // Re-trigger search with new options
+   searchQuery.value = 'another'
    await nextTick()

-   expect(filteredTemplates.value.length).toBeGreaterThan(0)
+   // Verify results changed due to different options
+   expect(filteredTemplates.value).not.toEqual(resultsBeforeOptionsLoad)
+   expect(filteredTemplates.value.length).toBeGreaterThan(0)
    expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
  })
🤖 Prompt for AI Agents
In @src/composables/useTemplateFiltering.test.ts around lines 346 - 382, The
test 'recreates Fuse instance when fuseOptions change' should assert that new
Fuse options actually change search behavior: before calling loadFuseOptions(),
set searchQuery.value to a test term and capture filteredTemplates.value (or
assert it’s empty/not matching expected), then mockGetFuseOptions to return
customFuseOptions, call loadFuseOptions() and await nextTick(), set
searchQuery.value to the same test term again and assert filteredTemplates.value
now reflects the new options (e.g., includes the expected template), and finally
assert mockGetFuseOptions was called once; use the existing symbols
loadFuseOptions, searchQuery, filteredTemplates, mockGetFuseOptions, and
customFuseOptions to locate and modify the test.

})
})
44 changes: 28 additions & 16 deletions src/composables/useTemplateFiltering.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
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'

import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
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(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
Expand All @@ -31,26 +47,14 @@ export function useTemplateFiltering(
| 'model-size-low-to-high'
>(settingStore.get('Comfy.Templates.SortBy'))

const fuseOptions = ref<IFuseOptions<TemplateInfo>>(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<string>()
Expand Down Expand Up @@ -237,6 +241,13 @@ export function useTemplateFiltering(
})
}, 500)

const loadFuseOptions = async () => {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
}
Comment on lines +244 to +249
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Error handling is still missing despite being marked as addressed.

The loadFuseOptions function lacks try-catch error handling, causing errors from api.getFuseOptions() to propagate to callers. This violates the coding guideline requiring proper error handling in TypeScript files, and was previously flagged but marked as addressed.

As currently implemented, a fetch failure in WorkflowTemplateSelectorDialog.vue will cause the entire Promise.all initialization to fail (see related comment on that file), blocking critical template loading operations.

🔎 Recommended fix with graceful fallback
  const loadFuseOptions = async () => {
+   try {
      const fetchedOptions = await api.getFuseOptions()
      if (fetchedOptions) {
        fuseOptions.value = fetchedOptions
      }
+   } catch (error) {
+     console.warn('Failed to load Fuse options, using defaults:', error)
+     // fuseOptions.value remains at defaultFuseOptions
+   }
  }

Based on coding guidelines requiring proper error handling in code.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const loadFuseOptions = async () => {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
}
const loadFuseOptions = async () => {
try {
const fetchedOptions = await api.getFuseOptions()
if (fetchedOptions) {
fuseOptions.value = fetchedOptions
}
} catch (error) {
console.warn('Failed to load Fuse options, using defaults:', error)
// fuseOptions.value remains at defaultFuseOptions
}
}
🤖 Prompt for AI Agents
In @src/composables/useTemplateFiltering.ts around lines 244 - 249, The
loadFuseOptions function lacks error handling; wrap the api.getFuseOptions()
call in a try-catch inside loadFuseOptions so errors don’t bubble up and break
Promise.all in WorkflowTemplateSelectorDialog.vue, and on failure log the error
(or report to processLogger) and leave fuseOptions.value at a safe fallback
(e.g., empty object/array or previous value) to allow initialization to
continue. Ensure you reference loadFuseOptions and api.getFuseOptions and set
fuseOptions.value to the fallback in the catch block.


// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
Expand Down Expand Up @@ -309,6 +320,7 @@ export function useTemplateFiltering(
resetFilters,
removeModelFilter,
removeUseCaseFilter,
removeRunsOnFilter
removeRunsOnFilter,
loadFuseOptions
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Fuse from 'fuse.js'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -525,7 +506,6 @@ export const useWorkflowTemplatesStore = defineStore(
groupedTemplates,
navGroupedTemplates,
enhancedTemplates,
templateFuse,
filterTemplatesByCategory,
isLoaded,
loadWorkflowTemplates,
Expand Down
17 changes: 17 additions & 0 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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
Expand Down Expand Up @@ -1266,6 +1267,22 @@ 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<unknown> | null> {
try {
const res = await axios.get(this.fileURL('/templates/fuse_options.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.
*
Expand Down