From c3fe311a72ddab913351bc4bc24b78d06507effd Mon Sep 17 00:00:00 2001 From: bymyself Date: Thu, 11 Dec 2025 03:08:06 -0800 Subject: [PATCH] search playground --- src/composables/useTemplateFiltering.ts | 53 +- src/locales/en/main.json | 149 ++- .../templates/utils/templateFuseOptions.ts | 206 ++++ .../utils/templateSearchLabInjection.ts | 4 + src/router.ts | 5 + src/schemas/apiSchema.ts | 30 + src/views/templates/TemplateSearchLab.vue | 937 ++++++++++++++++++ 7 files changed, 1362 insertions(+), 22 deletions(-) create mode 100644 src/platform/workflow/templates/utils/templateFuseOptions.ts create mode 100644 src/platform/workflow/templates/utils/templateSearchLabInjection.ts create mode 100644 src/views/templates/TemplateSearchLab.vue diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index c181e2f91..3d59a1d8b 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -1,11 +1,17 @@ import { refDebounced, watchDebounced } from '@vueuse/core' import Fuse from 'fuse.js' -import { computed, ref, watch } from 'vue' +import { computed, inject, 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 { + DEFAULT_TEMPLATE_FUSE_CONFIG, + TEMPLATE_FUSE_SETTINGS_KEY, + buildTemplateFuseOptions +} from '@/platform/workflow/templates/utils/templateFuseOptions' +import { TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY } from '@/platform/workflow/templates/utils/templateSearchLabInjection' import { debounce } from 'es-toolkit/compat' export function useTemplateFiltering( @@ -13,15 +19,19 @@ export function useTemplateFiltering( ) { const settingStore = useSettingStore() - const searchQuery = ref('') + const injectedSearchQuery = inject | null>( + TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY, + null + ) + const searchQuery = injectedSearchQuery ?? ref('') const selectedModels = ref( - settingStore.get('Comfy.Templates.SelectedModels') + settingStore.get('Comfy.Templates.SelectedModels') ?? [] ) const selectedUseCases = ref( - settingStore.get('Comfy.Templates.SelectedUseCases') + settingStore.get('Comfy.Templates.SelectedUseCases') ?? [] ) const selectedRunsOn = ref( - settingStore.get('Comfy.Templates.SelectedRunsOn') + settingStore.get('Comfy.Templates.SelectedRunsOn') ?? [] ) const sortBy = ref< | 'default' @@ -36,21 +46,24 @@ export function useTemplateFiltering( 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.2 }, - { name: 'tags', weight: 0.1 }, - { name: 'models', weight: 0.1 } - ], - threshold: 0.4, - includeScore: true, - includeMatches: true - } + const fuseConfig = computed( + () => + settingStore.get(TEMPLATE_FUSE_SETTINGS_KEY) ?? + DEFAULT_TEMPLATE_FUSE_CONFIG + ) - const fuse = computed(() => new Fuse(templatesArray.value, fuseOptions)) + const debouncedSearchQuery = refDebounced(searchQuery, 50) + + const fuse = computed( + () => + new Fuse( + templatesArray.value, + buildTemplateFuseOptions({ + config: fuseConfig.value, + query: debouncedSearchQuery.value.trim().toLowerCase() + }) + ) + ) const availableModels = computed(() => { const modelSet = new Set() @@ -76,8 +89,6 @@ export function useTemplateFiltering( return ['ComfyUI', 'External or Remote API'] }) - const debouncedSearchQuery = refDebounced(searchQuery, 50) - const filteredBySearch = computed(() => { if (!debouncedSearchQuery.value.trim()) { return templatesArray.value diff --git a/src/locales/en/main.json b/src/locales/en/main.json index df44b4f16..99a7a2901 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -880,6 +880,153 @@ "searchPlaceholder": "Search..." } }, + "templateSearchLab": { + "title": "Template Search Tuning Lab", + "subtitle": "Fine-tune Fuse.js options and instantly preview template results.", + "description": "Use real template data to decide how strict or fuzzy searches like \"Wan\" and \"Animate\" should feel before handing settings to engineering.", + "reset": "Reset to recommended defaults", + "configCopied": "Copied", + "configCopy": "Copy JSON", + "searchLabel": "Type a template, model, or tag", + "searchPlaceholder": "Search templates (try \"Wan\")", + "samplesLabel": "Quick queries", + "previewTitle": "Live Preview", + "previewCount": "{count} matches", + "loadingTemplates": "Loading template catalog...", + "previewEmptyState": "Start typing or tap a sample query to preview matches.", + "previewNoResults": "No templates match \"{query}\".", + "scoreLabel": "Score", + "matchLabel": "Match found in {field}", + "unknownField": "unknown field", + "matchFallback": "Enable includeMatches to view highlighted indices.", + "configHeading": "Fuse option snapshot", + "configSubheading": "Copy this block into the composable or share it with engineering.", + "additionalReadingTitle": "Helpful reading", + "additionalReadingSubtitle": "Deep dives that explain how scoring and extended syntax work.", + "previewSummaryIdle": "Waiting for a query…", + "previewSummaryEmpty": "Search ran but no templates matched.", + "previewSummaryActive": "Showing {count} of {total} templates.", + "docLinkLabel": "Docs", + "links": { + "apiDocs": "Fuse option reference", + "scoringTheory": "Scoring theory explainer", + "extendedSearch": "Extended search syntax" + }, + "dialogPreviewTitle": "Live Templates Dialog", + "dialogPreviewSubtitle": "Same component designers see in production, updated in real time as you tweak settings.", + "optionCountSuffix": "settings", + "optionGroups": { + "basic": { + "title": "Basic options", + "description": "Quick toggles that control how literal or fuzzy the search feels." + }, + "fuzzy": { + "title": "Fuzzy matching", + "description": "Control how tolerant we are of typos and offsets." + }, + "advanced": { + "title": "Advanced", + "description": "Ranking strategies, extended syntax, and scoring heuristics." + } + }, + "sortLabel": "Sort strategy", + "sortDescription": "Override Fuse's default ordering when designers want exact or prefix matches before fuzzy ones.", + "sortExample": "Use \"Exact title first\" when Wan-branded templates must show before descriptions that merely mention \"Wan\".", + "sortModes": { + "score": "Fuse score (default)", + "exact": "Exact title or name first", + "prefix": "Prefix / word-start boost" + }, + "getFnLabel": "Collection accessor", + "getFnDescription": "Control how array fields such as tags or models are flattened before scoring.", + "getFnExample": "Flatten tags/models if designers want \"Wan\" to match the model list even when the title differs.", + "getFnModes": { + "default": "Fuse default accessor", + "flatten": "Flatten arrays into readable strings" + }, + "keysHeading": "Keys & weights", + "keysDescription": "Pick which template fields feed the Fuse index and how much they matter.", + "keysHelper": "Weights are relative—Fuse normalizes them automatically.", + "keysAddLabel": "Field to add", + "keysWeightLabel": "Weight", + "keysAddButton": "Add field", + "keysOptions": { + "name": "Raw name", + "title": "Localized title", + "description": "Description", + "tags": "Tags", + "models": "Models", + "useCase": "Use case", + "sourceModule": "Source module" + }, + "keys": { + "nameDescription": "Matches internal slugs such as wan_image_diffusion.", + "titleDescription": "Searches the display title (localized), e.g., Wan Diffusion Starter.", + "descriptionDescription": "Looks through the marketing copy for keywords like \"face retouch\".", + "tagsDescription": "Covers curated tags (portrait, anime, product, etc.).", + "modelsDescription": "Matches referenced model names such as Wan or SDXL.", + "customDescription": "Custom weighting for {field}." + }, + "removeLabel": "Remove", + "options": { + "isCaseSensitive": { + "description": "Respect letter casing when matching template text.", + "example": "Turn on if \"WAN\" should not match \"Wan Diffusion\" in lowercase." + }, + "ignoreDiacritics": { + "description": "Treat accents and diacritics as plain letters.", + "example": "Let \"anime\" match templates tagged \"animé\" without needing the accent." + }, + "includeScore": { + "description": "Expose the Fuse score so you can see how confident each hit is.", + "example": "Helps compare how much higher \"Wan Diffusion\" scores than \"Glow Portrait\" for the same query." + }, + "includeMatches": { + "description": "Return the character indices of each match for highlighting.", + "example": "Needed when you want to visually highlight \"Wan\" inside descriptions or tags." + }, + "minMatchCharLength": { + "description": "Ignore matches shorter than this many characters.", + "example": "Set to 2 so a single \"w\" typed by accident does not reshuffle the template list." + }, + "shouldSort": { + "description": "Let Fuse sort by score. Turn off to keep the original grouping order.", + "example": "Disable when designers want curated ordering even while filtering by text." + }, + "findAllMatches": { + "description": "Continue searching after a perfect hit to surface every occurrence.", + "example": "Use when multiple models inside the description should all highlight \"Wan\" segments." + }, + "location": { + "description": "Bias toward matches near this index in the text.", + "example": "Keep near 0 if template titles should matter more than long descriptions." + }, + "threshold": { + "description": "Overall fuzziness. 0 is exact, 1 matches almost anything.", + "example": "0.2 is strict enough that \"Wan\" surfaces Wan-branded templates before unrelated blurbs." + }, + "distance": { + "description": "How far from the expected location a match can drift before being ignored.", + "example": "Lower values keep \"Wan\" from matching a paragraph hundreds of characters away." + }, + "ignoreLocation": { + "description": "When true, disable the location + distance penalty entirely.", + "example": "Great for template data where matches can live anywhere within descriptions or tags." + }, + "useExtendedSearch": { + "description": "Allow ^=, =, and other extended search syntax for power users.", + "example": "Type ^=wan to show only templates whose names start with Wan." + }, + "ignoreFieldNorm": { + "description": "Ignore the penalty for long fields.", + "example": "Enable when long descriptions should not count against simple titles." + }, + "fieldNormWeight": { + "description": "Scale how much field length matters in scoring.", + "example": "Set to 0 when you only care that \"Wan\" exists somewhere, no matter how wordy the field is." + } + } + }, "graphCanvasMenu": { "zoomIn": "Zoom In", "zoomOut": "Zoom Out", @@ -2392,4 +2539,4 @@ "recentReleases": "Recent releases", "helpCenterMenu": "Help Center Menu" } -} \ No newline at end of file +} diff --git a/src/platform/workflow/templates/utils/templateFuseOptions.ts b/src/platform/workflow/templates/utils/templateFuseOptions.ts new file mode 100644 index 000000000..d277eef24 --- /dev/null +++ b/src/platform/workflow/templates/utils/templateFuseOptions.ts @@ -0,0 +1,206 @@ +import { cloneDeep } from 'es-toolkit/compat' +import type { + IFuseOptions, + FuseSortFunction, + FuseSortFunctionArg +} from 'fuse.js' + +import type { TemplateInfo } from '@/platform/workflow/templates/types/template' + +export const TEMPLATE_FUSE_SETTINGS_KEY = 'Comfy.Templates.FuseOverrides' + +export type TemplateFuseSortMode = 'score' | 'exact' | 'prefix' +export type TemplateFuseGetMode = 'default' | 'flatten' + +export interface TemplateFuseOptionState { + isCaseSensitive: boolean + ignoreDiacritics: boolean + includeScore: boolean + includeMatches: boolean + minMatchCharLength: number + shouldSort: boolean + findAllMatches: boolean + location: number + threshold: number + distance: number + ignoreLocation: boolean + useExtendedSearch: boolean + ignoreFieldNorm: boolean + fieldNormWeight: number +} + +export interface TemplateFuseKeyConfig { + path: string + weight: number +} + +export interface TemplateFuseConfig { + options: TemplateFuseOptionState + keys: TemplateFuseKeyConfig[] + sortMode: TemplateFuseSortMode + getFnMode: TemplateFuseGetMode +} + +const DEFAULT_TEMPLATE_FUSE_OPTIONS: TemplateFuseOptionState = { + isCaseSensitive: false, + ignoreDiacritics: true, + includeScore: true, + includeMatches: true, + minMatchCharLength: 1, + shouldSort: true, + findAllMatches: false, + location: 0, + threshold: 0.4, + distance: 100, + ignoreLocation: false, + useExtendedSearch: false, + ignoreFieldNorm: false, + fieldNormWeight: 1 +} + +const DEFAULT_TEMPLATE_FUSE_KEYS: TemplateFuseKeyConfig[] = [ + { path: 'name', weight: 0.3 }, + { path: 'title', weight: 0.3 }, + { path: 'description', weight: 0.2 }, + { path: 'tags', weight: 0.1 }, + { path: 'models', weight: 0.1 } +] + +export const DEFAULT_TEMPLATE_FUSE_CONFIG: TemplateFuseConfig = { + options: DEFAULT_TEMPLATE_FUSE_OPTIONS, + keys: DEFAULT_TEMPLATE_FUSE_KEYS, + sortMode: 'score', + getFnMode: 'default' +} + +type TemplateLike = TemplateInfo & { + localizedTitle?: string +} + +type BuildOptionsParams = { + config?: TemplateFuseConfig | null + query?: string +} + +export function buildTemplateFuseOptions( + params?: BuildOptionsParams +): IFuseOptions { + const baseConfig = params?.config ?? DEFAULT_TEMPLATE_FUSE_CONFIG + const normalizedKeys = + baseConfig.keys.length > 0 ? baseConfig.keys : DEFAULT_TEMPLATE_FUSE_KEYS + + return { + ...cloneDeep(baseConfig.options), + keys: normalizedKeys.map((entry) => ({ + name: entry.path, + weight: entry.weight + })), + getFn: buildGetFn(baseConfig.getFnMode), + sortFn: buildSortFn(baseConfig.sortMode, params?.query) + } +} + +function buildGetFn(mode: TemplateFuseGetMode) { + if (mode !== 'flatten') { + return undefined + } + return (obj: T, path: string | string[]) => { + if (Array.isArray(path)) { + return path.map((segment) => stringifyValue(obj, segment)) + } + return stringifyValue(obj, path) + } +} + +function stringifyValue(object: TemplateLike, path: string) { + const raw = resolvePath(object, path) + if (raw == null) return '' + if (Array.isArray(raw)) { + return raw + .map((entry) => + typeof entry === 'string' ? entry : JSON.stringify(entry) + ) + .join(' ') + } + if (typeof raw === 'object') { + return Object.values(raw) + .map((value) => (typeof value === 'string' ? value : '')) + .join(' ') + } + return String(raw) +} + +function resolvePath(object: TemplateLike, path: string) { + return path.split('.').reduce((current, segment) => { + if (current && typeof current === 'object') { + return (current as Record)[segment] + } + return undefined + }, object) +} + +function buildSortFn( + mode: TemplateFuseSortMode, + query?: string +): FuseSortFunction | undefined { + if (mode === 'score') { + return undefined + } + const normalizedQuery = query?.toLowerCase().trim() + return (a: FuseSortFunctionArg, b: FuseSortFunctionArg) => { + const templateA = a.item as unknown as TemplateLike + const templateB = b.item as unknown as TemplateLike + if (!normalizedQuery) { + return compareScores(a, b) + } + + if (mode === 'exact') { + const aExact = isExactMatch(templateA, normalizedQuery) + const bExact = isExactMatch(templateB, normalizedQuery) + if (aExact !== bExact) { + return aExact ? -1 : 1 + } + return compareScores(a, b) + } + + const aPrefix = prefixScore(templateA, normalizedQuery) + const bPrefix = prefixScore(templateB, normalizedQuery) + if (aPrefix !== bPrefix) { + return bPrefix - aPrefix + } + return compareScores(a, b) + } +} + +function compareScores(a: FuseSortFunctionArg, b: FuseSortFunctionArg) { + const aScore = typeof a.score === 'number' ? a.score : 1 + const bScore = typeof b.score === 'number' ? b.score : 1 + return aScore - bScore +} + +function isExactMatch(template: TemplateLike, query: string) { + const title = formatTitle(template) + const name = (template.name || '').toLowerCase() + return title === query || name === query +} + +function prefixScore(template: TemplateLike, query: string) { + const title = formatTitle(template) + const name = (template.name || '').toLowerCase() + if (title.startsWith(query) || name.startsWith(query)) { + return 2 + } + if (title.includes(` ${query}`) || name.includes(` ${query}`)) { + return 1 + } + return 0 +} + +function formatTitle(template: TemplateLike) { + return ( + template.localizedTitle || + template.title || + template.name || + '' + ).toLowerCase() +} diff --git a/src/platform/workflow/templates/utils/templateSearchLabInjection.ts b/src/platform/workflow/templates/utils/templateSearchLabInjection.ts new file mode 100644 index 000000000..d85374c2d --- /dev/null +++ b/src/platform/workflow/templates/utils/templateSearchLabInjection.ts @@ -0,0 +1,4 @@ +import type { InjectionKey, Ref } from 'vue' + +export const TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY: InjectionKey> = + Symbol('TemplateSearchOverride') diff --git a/src/router.ts b/src/router.ts index 59a691322..de90ef952 100644 --- a/src/router.ts +++ b/src/router.ts @@ -67,6 +67,11 @@ const router = createRouter({ path: 'user-select', name: 'UserSelectView', component: () => import('@/views/UserSelectView.vue') + }, + { + path: 'designer/search-lab', + name: 'TemplateSearchLab', + component: () => import('@/views/templates/TemplateSearchLab.vue') } ] } diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 5187145cf..7510c0d4e 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -369,6 +369,35 @@ const zNodeBadgeMode = z.enum( Object.values(NodeBadgeMode) as [string, ...string[]] ) +const zTemplateFuseOptionState = z.object({ + isCaseSensitive: z.boolean(), + ignoreDiacritics: z.boolean(), + includeScore: z.boolean(), + includeMatches: z.boolean(), + minMatchCharLength: z.number(), + shouldSort: z.boolean(), + findAllMatches: z.boolean(), + location: z.number(), + threshold: z.number(), + distance: z.number(), + ignoreLocation: z.boolean(), + useExtendedSearch: z.boolean(), + ignoreFieldNorm: z.boolean(), + fieldNormWeight: z.number() +}) + +const zTemplateFuseKeyConfig = z.object({ + path: z.string(), + weight: z.number() +}) + +const zTemplateFuseOverrides = z.object({ + options: zTemplateFuseOptionState, + keys: z.array(zTemplateFuseKeyConfig), + sortMode: z.enum(['score', 'exact', 'prefix']), + getFnMode: z.enum(['default', 'flatten']) +}) + const zSettings = z.object({ 'Comfy.ColorPalette': z.string(), 'Comfy.CustomColorPalettes': colorPalettesSchema, @@ -518,6 +547,7 @@ const zSettings = z.object({ 'vram-low-to-high', 'model-size-low-to-high' ]), + 'Comfy.Templates.FuseOverrides': zTemplateFuseOverrides, /** Settings used for testing */ 'test.setting': z.any(), 'main.sub.setting.name': z.any(), diff --git a/src/views/templates/TemplateSearchLab.vue b/src/views/templates/TemplateSearchLab.vue new file mode 100644 index 000000000..bb55846ab --- /dev/null +++ b/src/views/templates/TemplateSearchLab.vue @@ -0,0 +1,937 @@ + + +