mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-24 08:19:51 +00:00
Compare commits
1 Commits
drjkl/roun
...
cb/search-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3fe311a72 |
@@ -1,11 +1,17 @@
|
|||||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
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'
|
import { debounce } from 'es-toolkit/compat'
|
||||||
|
|
||||||
export function useTemplateFiltering(
|
export function useTemplateFiltering(
|
||||||
@@ -13,15 +19,19 @@ export function useTemplateFiltering(
|
|||||||
) {
|
) {
|
||||||
const settingStore = useSettingStore()
|
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[]>(
|
const selectedModels = ref<string[]>(
|
||||||
settingStore.get('Comfy.Templates.SelectedModels')
|
settingStore.get('Comfy.Templates.SelectedModels') ?? []
|
||||||
)
|
)
|
||||||
const selectedUseCases = ref<string[]>(
|
const selectedUseCases = ref<string[]>(
|
||||||
settingStore.get('Comfy.Templates.SelectedUseCases')
|
settingStore.get('Comfy.Templates.SelectedUseCases') ?? []
|
||||||
)
|
)
|
||||||
const selectedRunsOn = ref<string[]>(
|
const selectedRunsOn = ref<string[]>(
|
||||||
settingStore.get('Comfy.Templates.SelectedRunsOn')
|
settingStore.get('Comfy.Templates.SelectedRunsOn') ?? []
|
||||||
)
|
)
|
||||||
const sortBy = ref<
|
const sortBy = ref<
|
||||||
| 'default'
|
| 'default'
|
||||||
@@ -36,21 +46,24 @@ export function useTemplateFiltering(
|
|||||||
return Array.isArray(templateData) ? templateData : []
|
return Array.isArray(templateData) ? templateData : []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fuse.js configuration for fuzzy search
|
const fuseConfig = computed(
|
||||||
const fuseOptions = {
|
() =>
|
||||||
keys: [
|
settingStore.get(TEMPLATE_FUSE_SETTINGS_KEY) ??
|
||||||
{ name: 'name', weight: 0.3 },
|
DEFAULT_TEMPLATE_FUSE_CONFIG
|
||||||
{ 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 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 availableModels = computed(() => {
|
||||||
const modelSet = new Set<string>()
|
const modelSet = new Set<string>()
|
||||||
@@ -76,8 +89,6 @@ export function useTemplateFiltering(
|
|||||||
return ['ComfyUI', 'External or Remote API']
|
return ['ComfyUI', 'External or Remote API']
|
||||||
})
|
})
|
||||||
|
|
||||||
const debouncedSearchQuery = refDebounced(searchQuery, 50)
|
|
||||||
|
|
||||||
const filteredBySearch = computed(() => {
|
const filteredBySearch = computed(() => {
|
||||||
if (!debouncedSearchQuery.value.trim()) {
|
if (!debouncedSearchQuery.value.trim()) {
|
||||||
return templatesArray.value
|
return templatesArray.value
|
||||||
|
|||||||
@@ -880,6 +880,153 @@
|
|||||||
"searchPlaceholder": "Search..."
|
"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": {
|
"graphCanvasMenu": {
|
||||||
"zoomIn": "Zoom In",
|
"zoomIn": "Zoom In",
|
||||||
"zoomOut": "Zoom Out",
|
"zoomOut": "Zoom Out",
|
||||||
@@ -2392,4 +2539,4 @@
|
|||||||
"recentReleases": "Recent releases",
|
"recentReleases": "Recent releases",
|
||||||
"helpCenterMenu": "Help Center Menu"
|
"helpCenterMenu": "Help Center Menu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
206
src/platform/workflow/templates/utils/templateFuseOptions.ts
Normal file
206
src/platform/workflow/templates/utils/templateFuseOptions.ts
Normal 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { InjectionKey, Ref } from 'vue'
|
||||||
|
|
||||||
|
export const TEMPLATE_SEARCH_QUERY_OVERRIDE_KEY: InjectionKey<Ref<string>> =
|
||||||
|
Symbol('TemplateSearchOverride')
|
||||||
@@ -67,6 +67,11 @@ const router = createRouter({
|
|||||||
path: 'user-select',
|
path: 'user-select',
|
||||||
name: 'UserSelectView',
|
name: 'UserSelectView',
|
||||||
component: () => import('@/views/UserSelectView.vue')
|
component: () => import('@/views/UserSelectView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'designer/search-lab',
|
||||||
|
name: 'TemplateSearchLab',
|
||||||
|
component: () => import('@/views/templates/TemplateSearchLab.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,6 +369,35 @@ const zNodeBadgeMode = z.enum(
|
|||||||
Object.values(NodeBadgeMode) as [string, ...string[]]
|
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({
|
const zSettings = z.object({
|
||||||
'Comfy.ColorPalette': z.string(),
|
'Comfy.ColorPalette': z.string(),
|
||||||
'Comfy.CustomColorPalettes': colorPalettesSchema,
|
'Comfy.CustomColorPalettes': colorPalettesSchema,
|
||||||
@@ -518,6 +547,7 @@ const zSettings = z.object({
|
|||||||
'vram-low-to-high',
|
'vram-low-to-high',
|
||||||
'model-size-low-to-high'
|
'model-size-low-to-high'
|
||||||
]),
|
]),
|
||||||
|
'Comfy.Templates.FuseOverrides': zTemplateFuseOverrides,
|
||||||
/** Settings used for testing */
|
/** Settings used for testing */
|
||||||
'test.setting': z.any(),
|
'test.setting': z.any(),
|
||||||
'main.sub.setting.name': z.any(),
|
'main.sub.setting.name': z.any(),
|
||||||
|
|||||||
937
src/views/templates/TemplateSearchLab.vue
Normal file
937
src/views/templates/TemplateSearchLab.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user