search playground

This commit is contained in:
bymyself
2025-12-11 03:08:06 -08:00
parent 72b5444d5a
commit c3fe311a72
7 changed files with 1362 additions and 22 deletions

View File

@@ -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<Ref<string> | null>(
TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY,
null
)
const searchQuery = injectedSearchQuery ?? ref('')
const selectedModels = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedModels')
settingStore.get('Comfy.Templates.SelectedModels') ?? []
)
const selectedUseCases = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedUseCases')
settingStore.get('Comfy.Templates.SelectedUseCases') ?? []
)
const selectedRunsOn = ref<string[]>(
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<TemplateInfo>({
config: fuseConfig.value,
query: debouncedSearchQuery.value.trim().toLowerCase()
})
)
)
const availableModels = computed(() => {
const modelSet = new Set<string>()
@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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<T extends TemplateLike>(
params?: BuildOptionsParams
): IFuseOptions<T> {
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<T>(baseConfig.getFnMode),
sortFn: buildSortFn(baseConfig.sortMode, params?.query)
}
}
function buildGetFn<T extends TemplateLike>(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<unknown>((current, segment) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[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()
}

View File

@@ -0,0 +1,4 @@
import type { InjectionKey, Ref } from 'vue'
export const TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY: InjectionKey<Ref<string>> =
Symbol('TemplateSearchOverride')

View File

@@ -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')
}
]
}

View File

@@ -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(),

View File

@@ -0,0 +1,937 @@
<template>
<div
class="grid min-h-screen gap-6 overflow-y-auto p-6 lg:grid-cols-[1.1fr_0.9fr]"
>
<div
class="flex max-h-[calc(100vh-3rem)] flex-col gap-6 overflow-y-auto pr-3"
>
<section
class="rounded-2xl border border-border-default bg-secondary-background p-6 shadow-sm"
>
<div
class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"
>
<div class="space-y-2">
<p
class="text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
{{ t('templateSearchLab.title') }}
</p>
<h1 class="text-2xl font-semibold text-base-foreground">
{{ t('templateSearchLab.subtitle') }}
</h1>
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.description') }}
</p>
<div class="flex flex-wrap gap-2">
<Button
size="small"
:label="t('templateSearchLab.reset')"
icon="icon-[lucide--refresh-cw]"
severity="secondary"
@click="resetLab"
/>
<Button
size="small"
:label="
copyStatus === 'copied'
? t('templateSearchLab.configCopied')
: t('templateSearchLab.configCopy')
"
:icon="
copyStatus === 'copied'
? 'icon-[lucide--check]'
: 'icon-[lucide--clipboard-copy]'
"
:severity="copyStatus === 'error' ? 'danger' : 'secondary'"
@click="copyOptions"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-muted-foreground">
<a
v-for="link in resourceLinks"
:key="link.href"
class="inline-flex items-center gap-2 text-sm font-medium text-text-primary underline-offset-4 hover:underline"
:href="link.href"
target="_blank"
rel="noreferrer"
>
<i :class="link.icon" />
<span>{{ link.label }}</span>
</a>
</div>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[1.6fr_1fr]">
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.searchLabel') }}
</label>
<SearchBox
v-model="searchQuery"
:placeholder="t('templateSearchLab.searchPlaceholder')"
show-border
/>
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span class="font-medium">{{
t('templateSearchLab.samplesLabel')
}}</span>
<button
v-for="sample in sampleQueries"
:key="sample"
type="button"
class="rounded-full border border-border-muted px-3 py-1 text-xs font-medium text-base-foreground transition hover:bg-base-background"
@click="searchQuery = sample"
>
{{ sample }}
</button>
</div>
</div>
<div
class="rounded-xl border border-border-default bg-secondary-background shadow-inner"
>
<div
class="flex items-center justify-between border-b border-border-muted px-4 py-3"
>
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.previewTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ previewSummary }}
</p>
</div>
<Tag v-if="searchQuery.trim().length" severity="secondary">
{{
t('templateSearchLab.previewCount', {
count: previewResults.length
})
}}
</Tag>
</div>
<div class="max-h-[420px] space-y-3 overflow-y-auto px-4 py-3">
<template v-if="!isLoaded">
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.loadingTemplates') }}
</p>
</template>
<template v-else-if="!searchQuery.trim().length">
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.previewEmptyState') }}
</p>
</template>
<template v-else-if="previewResults.length === 0">
<p class="text-sm text-danger-100">
{{
t('templateSearchLab.previewNoResults', {
query: searchQuery
})
}}
</p>
</template>
<template v-else>
<article
v-for="(result, index) in previewResults"
:key="`${result.item.name}-${index}`"
class="rounded-lg border border-border-muted bg-secondary-background px-4 py-3"
>
<div
class="flex flex-wrap items-start justify-between gap-2"
>
<div>
<p class="text-base font-semibold text-base-foreground">
{{ formatTemplateTitle(result.item) }}
</p>
<p class="text-xs text-muted-foreground">
{{ formatTemplateMeta(result.item) }}
</p>
</div>
<Tag
v-if="typeof result.score === 'number'"
severity="info"
>
{{ t('templateSearchLab.scoreLabel') }}:
{{ formatScore(result.score) }}
</Tag>
</div>
<div v-if="result.matches?.length" class="mt-3 space-y-2">
<div
v-for="match in result.matches"
:key="`${match.key}-${match.refIndex}`"
class="rounded-md border border-border-muted px-3 py-2"
>
<p
class="text-xs font-semibold uppercase text-muted-foreground"
>
{{
t('templateSearchLab.matchLabel', {
field:
match.key || t('templateSearchLab.unknownField')
})
}}
</p>
<p
v-if="typeof match.value === 'string'"
class="text-sm text-base-foreground"
>
<span
v-for="(chunk, chunkIndex) in buildHighlightChunks(
match.value,
match.indices || []
)"
:key="chunkIndex"
:class="
chunk.isHit
? 'rounded bg-base-background px-1 font-semibold text-base-foreground'
: ''
"
>
{{ chunk.text }}
</span>
</p>
<p v-else class="text-xs text-muted-foreground">
{{ formatNonStringMatch(match.value) }}
</p>
</div>
</div>
<p
v-else-if="designerOptions.includeMatches"
class="mt-3 text-xs text-muted-foreground"
>
{{ t('templateSearchLab.matchFallback') }}
</p>
</article>
</template>
</div>
</div>
</div>
<div class="space-y-4">
<div
class="rounded-xl border border-border-default bg-secondary-background p-4"
>
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.configHeading') }}
</p>
<i class="icon-[lucide--sparkles] text-muted-foreground" />
</div>
<p class="mt-1 text-xs text-muted-foreground">
{{ t('templateSearchLab.configSubheading') }}
</p>
<pre
class="mt-3 max-h-64 overflow-auto rounded-lg bg-base-background p-3 text-xs leading-relaxed text-base-foreground"
>{{ shareableConfig }}</pre
>
</div>
<div
class="rounded-xl border border-border-default bg-secondary-background p-4"
>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.additionalReadingTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.additionalReadingSubtitle') }}
</p>
<ul class="mt-3 space-y-2 text-sm">
<li
v-for="link in deepDiveLinks"
:key="link.href"
class="flex items-center gap-2 text-text-primary"
>
<i :class="link.icon" class="text-base" />
<a
class="hover:underline"
:href="link.href"
target="_blank"
rel="noreferrer"
>
{{ link.label }}
</a>
</li>
</ul>
</div>
</div>
</div>
</section>
<section class="space-y-6">
<div
v-for="group in optionGroups"
:key="group.key"
class="rounded-2xl border border-border-default bg-secondary-background p-6"
>
<div class="flex items-center justify-between">
<div>
<p class="text-lg font-semibold text-base-foreground">
{{ group.title }}
</p>
<p class="text-sm text-muted-foreground">
{{ group.description }}
</p>
</div>
<Tag severity="secondary"
>{{ group.options.length }}
{{ t('templateSearchLab.optionCountSuffix') }}</Tag
>
</div>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div
v-for="option in group.options"
:key="option.key"
class="rounded-xl border border-border-muted bg-secondary-background p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-base-foreground">
{{ option.label }}
</p>
<p class="text-xs text-muted-foreground">
{{ option.description }}
</p>
<p class="mt-1 text-xs font-medium text-text-primary">
{{ option.example }}
</p>
</div>
<a
class="text-xs font-semibold text-text-primary underline-offset-4 hover:underline"
:href="docLink(option.anchor)"
target="_blank"
rel="noreferrer"
>
{{ t('templateSearchLab.docLinkLabel') }}
</a>
</div>
<div class="mt-4 space-y-3">
<ToggleSwitch
v-if="option.type === 'boolean'"
v-model="
designerOptions[option.key as keyof DesignerToggleOptions]
"
:input-id="`toggle-${option.key}`"
/>
<div v-else-if="option.type === 'number'" class="space-y-2">
<Slider
v-model.number="
designerOptions[
option.key as keyof DesignerNumericOptions
]
"
:min="option.min"
:max="option.max"
:step="option.step"
/>
<InputNumber
v-model.number="
designerOptions[
option.key as keyof DesignerNumericOptions
]
"
:min="option.min"
:max="option.max"
:step="option.step"
class="w-full"
/>
</div>
<div v-else-if="option.type === 'sort'" class="space-y-2">
<Select
v-model="sortMode"
:options="sortOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.sortDescription') }}
</p>
</div>
<div v-else-if="option.type === 'get'" class="space-y-2">
<Select
v-model="getFnMode"
:options="getFnOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.getFnDescription') }}
</p>
</div>
</div>
</div>
</div>
</div>
<div
class="rounded-2xl border border-border-default bg-secondary-background p-6"
>
<div class="flex items-center justify-between">
<div>
<p class="text-lg font-semibold text-base-foreground">
{{ t('templateSearchLab.keysHeading') }}
</p>
<p class="text-sm text-muted-foreground">
{{ t('templateSearchLab.keysDescription') }}
</p>
</div>
<a
class="text-xs font-semibold text-text-primary underline-offset-4 hover:underline"
:href="docLink('keys')"
target="_blank"
rel="noreferrer"
>
{{ t('templateSearchLab.docLinkLabel') }}
</a>
</div>
<p class="mt-2 text-xs text-muted-foreground">
{{ t('templateSearchLab.keysHelper') }}
</p>
<div class="mt-4 flex flex-wrap items-end gap-3">
<div class="grow">
<label class="text-xs font-semibold text-muted-foreground">
{{ t('templateSearchLab.keysAddLabel') }}
</label>
<Select
v-model="keyToAdd"
:options="keyLibrary"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="text-xs font-semibold text-muted-foreground">
{{ t('templateSearchLab.keysWeightLabel') }}
</label>
<InputNumber
v-model.number="newKeyWeight"
:min="0.05"
:max="1"
:step="0.05"
/>
</div>
<Button
:label="t('templateSearchLab.keysAddButton')"
icon="icon-[lucide--plus-circle]"
:disabled="!keyToAdd"
@click="addKey"
/>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div
v-for="key in fuseKeyEntries"
:key="key.id"
class="rounded-xl border border-border-muted bg-secondary-background p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ key.path }}
</p>
<p class="text-xs text-muted-foreground">
{{ describeKey(key.path) }}
</p>
</div>
<button
type="button"
class="text-xs font-semibold text-danger-100 disabled:text-muted-foreground"
:disabled="fuseKeyEntries.length === 1"
@click="removeKey(key.id)"
>
{{ t('templateSearchLab.removeLabel') }}
</button>
</div>
<div class="mt-3 space-y-2">
<InputText v-model="key.path" class="w-full" />
<div class="flex items-center gap-3">
<Slider
v-model.number="key.weight"
:min="0.05"
:max="1"
:step="0.05"
/>
<InputNumber
v-model.number="key.weight"
:min="0.05"
:max="1"
:step="0.05"
/>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<aside
class="flex flex-col gap-4 rounded-2xl border border-border-default bg-secondary-background p-4"
>
<div>
<p class="text-sm font-semibold text-base-foreground">
{{ t('templateSearchLab.dialogPreviewTitle') }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('templateSearchLab.dialogPreviewSubtitle') }}
</p>
</div>
<div
class="min-h-[70vh] flex-1 overflow-hidden rounded-xl border border-border-default bg-base-background"
>
<WorkflowTemplateSelectorDialog :on-close="noop" />
</div>
</aside>
</div>
</template>
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { RangeTuple } from 'fuse.js'
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
import Slider from 'primevue/slider'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, onMounted, provide, reactive, ref } from 'vue'
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
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 type {
TemplateFuseConfig,
TemplateFuseGetMode,
TemplateFuseKeyConfig,
TemplateFuseOptionState,
TemplateFuseSortMode
} from '@/platform/workflow/templates/utils/templateFuseOptions'
import { TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY } from '@/platform/workflow/templates/utils/templateSearchLabInjection'
interface TemplateSearchRecord extends TemplateInfo {
localizedTitle?: string
localizedDescription?: string
tags?: string[]
models?: string[]
sourceModule?: string
searchableText?: string
}
interface HighlightChunk {
text: string
isHit: boolean
}
interface FuseKeyEntry {
id: string
path: string
weight: number
}
interface OptionDefinition {
key: keyof DesignerOptions | 'sort' | 'get'
label: string
description: string
example: string
anchor: string
type: 'boolean' | 'number' | 'sort' | 'get'
min?: number
max?: number
step?: number
}
interface OptionGroup {
key: string
title: string
description: string
options: OptionDefinition[]
}
type TemplateFuseToggleKey =
| 'isCaseSensitive'
| 'ignoreDiacritics'
| 'includeScore'
| 'includeMatches'
| 'shouldSort'
| 'findAllMatches'
| 'ignoreLocation'
| 'useExtendedSearch'
| 'ignoreFieldNorm'
type TemplateFuseNumberKey =
| 'minMatchCharLength'
| 'location'
| 'threshold'
| 'distance'
| 'fieldNormWeight'
type DesignerOptions = TemplateFuseOptionState
type DesignerToggleOptions = Pick<
TemplateFuseOptionState,
TemplateFuseToggleKey
>
type DesignerNumericOptions = Pick<
TemplateFuseOptionState,
TemplateFuseNumberKey
>
type SortMode = TemplateFuseSortMode
type GetFnMode = TemplateFuseGetMode
const DOCS_BASE_URL = 'https://www.fusejs.io/api/options.html'
let keyIdCounter = 0
const createKeyId = () => `fuse-key-${++keyIdCounter}`
const settingStore = useSettingStore()
const initialConfig =
settingStore.get(TEMPLATE_FUSE_SETTINGS_KEY) ?? DEFAULT_TEMPLATE_FUSE_CONFIG
const designerOptions = reactive<DesignerOptions>({
...initialConfig.options
})
const fuseKeyEntries = ref<FuseKeyEntry[]>(buildKeyEntries(initialConfig.keys))
const sortMode = ref<SortMode>(initialConfig.sortMode)
const getFnMode = ref<GetFnMode>(initialConfig.getFnMode)
const searchQuery = ref('wan')
const sampleQueries = ['wan', 'refiner', 'animate', 'face', 'stylized']
const keyToAdd = ref('models')
const newKeyWeight = ref(0.15)
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
const noop = () => {}
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { enhancedTemplates, isLoaded } = storeToRefs(workflowTemplatesStore)
onMounted(async () => {
if (!isLoaded.value) {
await workflowTemplatesStore.loadWorkflowTemplates()
}
})
const templateRecords = computed<TemplateSearchRecord[]>(
() => enhancedTemplates.value as TemplateSearchRecord[]
)
const normalizedQuery = computed(() => searchQuery.value.trim().toLowerCase())
provide(TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY, searchQuery)
const currentConfig = computed<TemplateFuseConfig>(() => ({
options: { ...designerOptions },
keys: fuseKeyEntries.value
.filter((entry) => entry.path.trim().length)
.map((entry) => ({ path: entry.path.trim(), weight: entry.weight })),
sortMode: sortMode.value,
getFnMode: getFnMode.value
}))
watchDebounced(
() => currentConfig.value,
(config) => {
void settingStore.set(TEMPLATE_FUSE_SETTINGS_KEY, config)
},
{ debounce: 250, deep: true }
)
const fuseOptions = computed(() =>
buildTemplateFuseOptions<TemplateSearchRecord>({
config: currentConfig.value,
query: normalizedQuery.value
})
)
const previewResults = computed(() => {
const templates = templateRecords.value
if (!templates.length || !normalizedQuery.value.length) {
return []
}
const fuse = new Fuse(templates, fuseOptions.value)
return fuse.search(normalizedQuery.value, { limit: 30 })
})
const previewSummary = computed(() => {
if (!searchQuery.value.trim().length) {
return t('templateSearchLab.previewSummaryIdle')
}
if (!isLoaded.value) {
return t('templateSearchLab.loadingTemplates')
}
if (previewResults.value.length === 0) {
return t('templateSearchLab.previewSummaryEmpty')
}
return t('templateSearchLab.previewSummaryActive', {
count: previewResults.value.length,
total: templateRecords.value.length
})
})
const shareableConfig = computed(() =>
JSON.stringify(currentConfig.value, null, 2)
)
const resourceLinks = computed(() => [
{
href: docLink(''),
label: t('templateSearchLab.links.apiDocs'),
icon: 'icon-[lucide--book-open]'
},
{
href: 'https://www.fusejs.io/concepts/scoring-theory.html',
label: t('templateSearchLab.links.scoringTheory'),
icon: 'icon-[lucide--line-chart]'
},
{
href: 'https://www.fusejs.io/examples.html#extended-search',
label: t('templateSearchLab.links.extendedSearch'),
icon: 'icon-[lucide--filter]'
}
])
const deepDiveLinks = computed(() => resourceLinks.value.slice(1))
const sortOptions = computed(() => [
{ value: 'score', label: t('templateSearchLab.sortModes.score') },
{ value: 'exact', label: t('templateSearchLab.sortModes.exact') },
{ value: 'prefix', label: t('templateSearchLab.sortModes.prefix') }
])
const getFnOptions = computed(() => [
{ value: 'default', label: t('templateSearchLab.getFnModes.default') },
{ value: 'flatten', label: t('templateSearchLab.getFnModes.flatten') }
])
const keyLibrary = computed(() => [
{ value: 'name', label: t('templateSearchLab.keysOptions.name') },
{ value: 'title', label: t('templateSearchLab.keysOptions.title') },
{
value: 'description',
label: t('templateSearchLab.keysOptions.description')
},
{ value: 'tags', label: t('templateSearchLab.keysOptions.tags') },
{ value: 'models', label: t('templateSearchLab.keysOptions.models') },
{ value: 'useCase', label: t('templateSearchLab.keysOptions.useCase') },
{
value: 'sourceModule',
label: t('templateSearchLab.keysOptions.sourceModule')
}
])
const optionGroups = computed<OptionGroup[]>(() => [
{
key: 'basic',
title: t('templateSearchLab.optionGroups.basic.title'),
description: t('templateSearchLab.optionGroups.basic.description'),
options: [
createToggleOption('isCaseSensitive', 'iscasesensitive'),
createToggleOption('ignoreDiacritics', 'ignorediacritics'),
createToggleOption('includeScore', 'includescore'),
createToggleOption('includeMatches', 'includematches'),
createNumericOption('minMatchCharLength', 'minmatchcharlength', 1, 10, 1),
createToggleOption('shouldSort', 'shouldsort'),
createToggleOption('findAllMatches', 'findallmatches')
]
},
{
key: 'fuzzy',
title: t('templateSearchLab.optionGroups.fuzzy.title'),
description: t('templateSearchLab.optionGroups.fuzzy.description'),
options: [
createNumericOption('location', 'location', 0, 500, 5),
createNumericOption('threshold', 'threshold', 0, 1, 0.01),
createNumericOption('distance', 'distance', 0, 1000, 10),
createToggleOption('ignoreLocation', 'ignorelocation')
]
},
{
key: 'advanced',
title: t('templateSearchLab.optionGroups.advanced.title'),
description: t('templateSearchLab.optionGroups.advanced.description'),
options: [
createToggleOption('useExtendedSearch', 'useextendedsearch'),
{
key: 'sort',
label: t('templateSearchLab.sortLabel'),
description: t('templateSearchLab.sortDescription'),
example: t('templateSearchLab.sortExample'),
anchor: 'sortFn',
type: 'sort'
},
{
key: 'get',
label: t('templateSearchLab.getFnLabel'),
description: t('templateSearchLab.getFnDescription'),
example: t('templateSearchLab.getFnExample'),
anchor: 'getFn',
type: 'get'
},
createToggleOption('ignoreFieldNorm', 'ignorefieldnorm'),
createNumericOption('fieldNormWeight', 'fieldnormweight', 0, 2, 0.1)
]
}
])
function createToggleOption(
key: keyof DesignerToggleOptions,
anchor: string
): OptionDefinition {
return {
key,
anchor,
label: key,
description: t(`templateSearchLab.options.${String(key)}.description`),
example: t(`templateSearchLab.options.${String(key)}.example`),
type: 'boolean'
}
}
function createNumericOption(
key: keyof DesignerNumericOptions,
anchor: string,
min: number,
max: number,
step: number
): OptionDefinition {
return {
key,
anchor,
label: key,
description: t(`templateSearchLab.options.${String(key)}.description`),
example: t(`templateSearchLab.options.${String(key)}.example`),
type: 'number',
min,
max,
step
}
}
function buildHighlightChunks(
text: string,
indices: readonly RangeTuple[]
): HighlightChunk[] {
if (!indices.length) {
return [{ text, isHit: false }]
}
const chunks: HighlightChunk[] = []
let lastIndex = 0
indices.forEach(([start, end]) => {
if (start > lastIndex) {
chunks.push({ text: text.slice(lastIndex, start), isHit: false })
}
chunks.push({ text: text.slice(start, end + 1), isHit: true })
lastIndex = end + 1
})
if (lastIndex < text.length) {
chunks.push({ text: text.slice(lastIndex), isHit: false })
}
return chunks
}
function formatScore(score: number) {
return score.toFixed(3)
}
function formatTemplateTitle(template: TemplateSearchRecord) {
return template.title || template.localizedTitle || template.name
}
function formatTemplateMeta(template: TemplateSearchRecord) {
const runsOn = template.openSource === false ? 'External API' : 'ComfyUI'
const models = template.models?.slice(0, 2).join(', ')
return [runsOn, models].filter(Boolean).join(' • ')
}
function formatNonStringMatch(value: unknown) {
if (Array.isArray(value)) {
return value.join(', ')
}
return String(value)
}
function docLink(anchor: string) {
return anchor ? `${DOCS_BASE_URL}#${anchor}` : DOCS_BASE_URL
}
function describeKey(path: string) {
switch (path) {
case 'name':
return t('templateSearchLab.keys.nameDescription')
case 'title':
return t('templateSearchLab.keys.titleDescription')
case 'description':
return t('templateSearchLab.keys.descriptionDescription')
case 'tags':
return t('templateSearchLab.keys.tagsDescription')
case 'models':
return t('templateSearchLab.keys.modelsDescription')
default:
return t('templateSearchLab.keys.customDescription', { field: path })
}
}
function buildKeyEntries(keys: TemplateFuseKeyConfig[]): FuseKeyEntry[] {
if (!keys.length) {
return DEFAULT_TEMPLATE_FUSE_CONFIG.keys.map((key) => createKeyEntry(key))
}
return keys.map((key) => createKeyEntry(key))
}
function createKeyEntry(config: TemplateFuseKeyConfig): FuseKeyEntry {
return {
id: createKeyId(),
path: config.path,
weight: config.weight
}
}
function addKey() {
if (!keyToAdd.value) {
return
}
fuseKeyEntries.value.push(
createKeyEntry({ path: keyToAdd.value, weight: newKeyWeight.value })
)
}
function removeKey(id: string) {
if (fuseKeyEntries.value.length === 1) {
return
}
fuseKeyEntries.value = fuseKeyEntries.value.filter((entry) => entry.id !== id)
}
async function copyOptions() {
try {
await navigator.clipboard.writeText(shareableConfig.value)
copyStatus.value = 'copied'
window.setTimeout(() => {
copyStatus.value = 'idle'
}, 2000)
} catch (error) {
console.error(error)
copyStatus.value = 'error'
}
}
function resetLab() {
applyConfig(DEFAULT_TEMPLATE_FUSE_CONFIG)
searchQuery.value = 'wan'
}
function applyConfig(config: TemplateFuseConfig) {
Object.assign(designerOptions, config.options)
fuseKeyEntries.value = buildKeyEntries(config.keys)
sortMode.value = config.sortMode
getFnMode.value = config.getFnMode
}
</script>