feat: add "Similar to Current" sort option to template selector

Wire the existing templateSimilarity module into the template selector
dialog so users can rank templates by how closely they match the nodes
in their active workflow. The sort extracts node types from the current
graph and scores each template using weighted Jaccard similarity across
categories, tags, models, and required nodes.

- Add 'similar-to-current' to sort dropdown, schema enum, type union,
  and telemetry type
- Export computeSimilarity from templateSimilarity.ts for direct use
- Add i18n key templateWorkflows.sort.similarToCurrent
- Bump version to 1.44.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John Haugeland
2026-02-24 15:42:31 -08:00
parent 07d49cbe64
commit 55dea32e00
9 changed files with 541 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.43.0",
"version": "1.44.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -724,6 +724,10 @@ const sortOptions = computed(() => [
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.similarToCurrent', 'Similar to Current'),
value: 'similar-to-current'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'

View File

@@ -44,6 +44,15 @@ vi.mock('@/platform/telemetry', () => ({
}))
}))
const mockGraphNodes = vi.hoisted(() => ({ value: [] as { type: string }[] }))
vi.mock('@/scripts/app', () => ({
app: {
get graph() {
return { _nodes: mockGraphNodes.value }
}
}
}))
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
@@ -280,6 +289,48 @@ describe('useTemplateFiltering', () => {
])
})
it('sorts by similarity to current workflow nodes', () => {
mockGraphNodes.value = [
{ type: 'KSampler' },
{ type: 'VAEDecode' },
{ type: 'CLIPTextEncode' }
]
const templates = ref<TemplateInfo[]>([
{
name: 'unrelated',
description: 'no overlap',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['ThreeDLoader']
},
{
name: 'best-match',
description: 'shares two nodes',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['KSampler', 'VAEDecode']
},
{
name: 'partial-match',
description: 'shares one node',
mediaType: 'image',
mediaSubtype: 'png',
requiresCustomNodes: ['KSampler', 'ThreeDLoader']
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'similar-to-current'
expect(filteredTemplates.value.map((t) => t.name)).toEqual([
'best-match',
'partial-match',
'unrelated'
])
})
describe('loadFuseOptions', () => {
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
const templates = ref<TemplateInfo[]>([

View File

@@ -7,7 +7,13 @@ 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 { app } from '@/scripts/app'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
import {
computeSimilarity,
toSimilarityInput
} from '@/utils/templateSimilarity'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
@@ -50,6 +56,7 @@ export function useTemplateFiltering(
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
| 'similar-to-current'
>(settingStore.get('Comfy.Templates.SortBy'))
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
@@ -270,6 +277,18 @@ export function useTemplateFiltering(
if (sizeA === sizeB) return 0
return sizeA - sizeB
})
case 'similar-to-current': {
const nodes = app.graph?._nodes ?? []
const reference: TemplateSimilarityInput = {
name: '',
requiredNodes: nodes.map((n) => n.type)
}
return templates.sort((a, b) => {
const scoreA = computeSimilarity(reference, toSimilarityInput(a))
const scoreB = computeSimilarity(reference, toSimilarityInput(b))
return scoreB - scoreA
})
}
case 'default':
default:
return templates

View File

@@ -1004,7 +1004,8 @@
"searchPlaceholder": "Search...",
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
"default": "Default",
"similarToCurrent": "Similar to Current"
},
"error": {
"templateNotFound": "Template \"{templateName}\" not found"
@@ -1115,7 +1116,21 @@
},
"previewGeneration": {
"title": "Preview",
"description": "Generate preview images and videos"
"description": "Generate preview images and videos",
"thumbnailLabel": "Thumbnail",
"thumbnailHint": "Primary image shown in marketplace listings",
"comparisonLabel": "Before & After Comparison",
"comparisonHint": "Show what the workflow transforms",
"beforeImageLabel": "Before",
"afterImageLabel": "After",
"workflowPreviewLabel": "Workflow Graph",
"workflowPreviewHint": "Screenshot of the workflow graph layout",
"videoPreviewLabel": "Video Preview",
"videoPreviewHint": "Optional short video demonstrating the workflow",
"galleryLabel": "Example Gallery",
"galleryHint": "Up to {max} example output images",
"uploadPrompt": "Click to upload",
"removeFile": "Remove"
},
"categoryAndTagging": {
"title": "Categories & Tags",
@@ -1135,6 +1150,33 @@
}
}
},
"developerProfile": {
"dialogTitle": "Developer Profile",
"username": "Username",
"bio": "Bio",
"reviews": "Reviews",
"publishedTemplates": "Published Templates",
"totalDownloads": "Downloads",
"totalFavorites": "Favorites",
"averageRating": "Avg. Rating",
"templateCount": "Templates",
"revenue": "Revenue",
"monthlyRevenue": "Monthly",
"totalRevenue": "Total",
"noReviews": "No reviews yet",
"noTemplates": "No published templates yet",
"unpublish": "Unpublish",
"save": "Save Profile",
"saving": "Saving...",
"verified": "Verified",
"quickActions": "Quick Actions",
"bannerPlaceholder": "Banner image",
"editUsername": "Edit username",
"editBio": "Edit bio",
"downloads": "Downloads",
"favorites": "Favorites",
"rating": "Rating"
},
"subgraphStore": {
"confirmDeleteTitle": "Delete blueprint?",
"confirmDelete": "This action will permanently remove the blueprint from your library",
@@ -1409,7 +1451,8 @@
"Model Library": "Model Library",
"Node Library": "Node Library",
"Workflows": "Workflows",
"templatePublishing": "Template Publishing"
"templatePublishing": "Template Publishing",
"developerProfile": "Developer Profile"
},
"desktopMenu": {
"reinstall": "Reinstall",

View File

@@ -222,6 +222,7 @@ export interface TemplateFilterMetadata {
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
| 'similar-to-current'
filtered_count: number
total_count: number
}

View File

@@ -450,7 +450,8 @@ const zSettings = z.object({
'alphabetical',
'newest',
'vram-low-to-high',
'model-size-low-to-high'
'model-size-low-to-high',
'similar-to-current'
]),
/** Settings used for testing */
'test.setting': z.any(),

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import type { TemplateSimilarityInput } from '@/utils/templateSimilarity'
import {
findSimilarTemplates,
toSimilarityInput
} from '@/utils/templateSimilarity'
function makeTemplate(
overrides: Partial<TemplateSimilarityInput> & { name: string }
): TemplateSimilarityInput {
return {
categories: [],
tags: [],
models: [],
requiredNodes: [],
...overrides
}
}
describe('templateSimilarity', () => {
describe('findSimilarTemplates', () => {
it('returns templates sorted by descending similarity score', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const bestMatch = makeTemplate({
name: 'best',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const partialMatch = makeTemplate({
name: 'partial',
categories: ['image-generation'],
tags: ['sdxl']
})
const weakMatch = makeTemplate({
name: 'weak',
tags: ['flux']
})
const results = findSimilarTemplates(reference, [
weakMatch,
bestMatch,
partialMatch
])
expect(results.map((r) => r.template.name)).toEqual([
'best',
'partial',
'weak'
])
})
it('excludes the reference template from results', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const clone = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const other = makeTemplate({
name: 'other',
categories: ['image-generation']
})
const results = findSimilarTemplates(reference, [clone, other])
expect(results).toHaveLength(1)
expect(results[0].template.name).toBe('other')
})
it('excludes templates with zero similarity', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux']
})
const noOverlap = makeTemplate({
name: 'none',
categories: ['audio'],
tags: ['tts']
})
const results = findSimilarTemplates(reference, [noOverlap])
expect(results).toHaveLength(0)
})
it('respects the limit parameter', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation']
})
const candidates = Array.from({ length: 20 }, (_, i) =>
makeTemplate({ name: `t-${i}`, categories: ['image-generation'] })
)
const results = findSimilarTemplates(reference, candidates, 5)
expect(results).toHaveLength(5)
})
it('returns empty array when no candidates match', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['video-generation']
})
const candidates = [
makeTemplate({ name: 'a', categories: ['audio'] }),
makeTemplate({ name: 'b', categories: ['text'] })
]
expect(findSimilarTemplates(reference, candidates)).toEqual([])
})
it('returns empty array when candidates list is empty', () => {
const reference = makeTemplate({ name: 'ref', tags: ['flux'] })
expect(findSimilarTemplates(reference, [])).toEqual([])
})
it('returns empty array when all templates have empty metadata', () => {
const reference = makeTemplate({ name: 'ref' })
const candidates = [
makeTemplate({ name: 'a' }),
makeTemplate({ name: 'b' })
]
expect(findSimilarTemplates(reference, candidates)).toEqual([])
})
it('ranks category match higher than tag-only match', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux']
})
const categoryOnly = makeTemplate({
name: 'cat',
categories: ['image-generation']
})
const tagOnly = makeTemplate({
name: 'tag',
tags: ['flux']
})
const results = findSimilarTemplates(reference, [tagOnly, categoryOnly])
expect(results[0].template.name).toBe('cat')
expect(results[0].score).toBeGreaterThan(results[1].score)
})
it('ranks shared models higher than shared tags', () => {
const reference = makeTemplate({
name: 'ref',
tags: ['txt2img'],
models: ['flux-dev']
})
const modelMatch = makeTemplate({
name: 'model',
models: ['flux-dev']
})
const tagMatch = makeTemplate({
name: 'tag',
tags: ['txt2img']
})
const results = findSimilarTemplates(reference, [tagMatch, modelMatch])
expect(results[0].template.name).toBe('model')
expect(results[0].score).toBeGreaterThan(results[1].score)
})
it('scores identical templates at 1.0', () => {
const reference = makeTemplate({
name: 'ref',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev'],
requiredNodes: ['node-a']
})
const identical = makeTemplate({
name: 'twin',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev'],
requiredNodes: ['node-a']
})
const results = findSimilarTemplates(reference, [identical])
expect(results[0].score).toBeCloseTo(1.0)
})
it('scores a real-world scenario correctly', () => {
const reference = makeTemplate({
name: 'flux-basic',
categories: ['image-generation'],
tags: ['flux', 'txt2img'],
models: ['flux-dev']
})
const similar = makeTemplate({
name: 'flux-advanced',
categories: ['image-generation'],
tags: ['flux', 'img2img'],
models: ['flux-dev']
})
const unrelated = makeTemplate({
name: 'audio-gen',
categories: ['audio'],
tags: ['tts', 'speech'],
models: ['bark']
})
const results = findSimilarTemplates(reference, [unrelated, similar])
expect(results).toHaveLength(1)
expect(results[0].template.name).toBe('flux-advanced')
expect(results[0].score).toBeGreaterThan(0.5)
})
})
describe('toSimilarityInput', () => {
it('wraps single category string into array', () => {
const result = toSimilarityInput({
name: 'test',
category: 'image-generation',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.categories).toEqual(['image-generation'])
})
it('returns empty categories when category is undefined', () => {
const result = toSimilarityInput({
name: 'test',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.categories).toEqual([])
})
it('passes tags through unchanged', () => {
const result = toSimilarityInput({
name: 'test',
tags: ['flux', 'txt2img'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.tags).toEqual(['flux', 'txt2img'])
})
it('passes models through unchanged', () => {
const result = toSimilarityInput({
name: 'test',
models: ['flux-dev', 'sd-vae'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.models).toEqual(['flux-dev', 'sd-vae'])
})
it('maps requiresCustomNodes to requiredNodes', () => {
const result = toSimilarityInput({
name: 'test',
requiresCustomNodes: ['node-a', 'node-b'],
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.requiredNodes).toEqual(['node-a', 'node-b'])
})
it('defaults missing arrays to empty arrays', () => {
const result = toSimilarityInput({
name: 'test',
mediaType: 'image',
mediaSubtype: 'png',
description: ''
})
expect(result.tags).toEqual([])
expect(result.models).toEqual([])
expect(result.requiredNodes).toEqual([])
})
})
})

View File

@@ -0,0 +1,113 @@
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { intersection, union } from 'es-toolkit'
/**
* Minimal metadata fields used by the similarity scorer.
* Both EnhancedTemplate and MarketplaceTemplate can satisfy this
* interface directly or via {@link toSimilarityInput}.
*/
export interface TemplateSimilarityInput {
readonly name: string
readonly categories?: readonly string[]
readonly tags?: readonly string[]
readonly models?: readonly string[]
readonly requiredNodes?: readonly string[]
}
/**
* A candidate template paired with its similarity score.
*/
export interface SimilarTemplate<T extends TemplateSimilarityInput> {
readonly template: T
readonly score: number
}
/** Per-dimension weights for the similarity formula. */
const SIMILARITY_WEIGHTS = {
categories: 0.35,
tags: 0.25,
models: 0.3,
requiredNodes: 0.1
} as const
/**
* Compute Jaccard similarity between two string arrays.
* Returns 0 when both arrays are empty (no evidence of similarity).
*/
function jaccardSimilarity(a: readonly string[], b: readonly string[]): number {
const u = union([...a], [...b])
if (u.length === 0) return 0
return intersection([...a], [...b]).length / u.length
}
/**
* Score the similarity between two templates based on shared metadata.
* Returns a value in [0, 1] where 1 is identical and 0 is no overlap.
*/
export function computeSimilarity(
reference: TemplateSimilarityInput,
candidate: TemplateSimilarityInput
): number {
return (
SIMILARITY_WEIGHTS.categories *
jaccardSimilarity(
reference.categories ?? [],
candidate.categories ?? []
) +
SIMILARITY_WEIGHTS.tags *
jaccardSimilarity(reference.tags ?? [], candidate.tags ?? []) +
SIMILARITY_WEIGHTS.models *
jaccardSimilarity(reference.models ?? [], candidate.models ?? []) +
SIMILARITY_WEIGHTS.requiredNodes *
jaccardSimilarity(
reference.requiredNodes ?? [],
candidate.requiredNodes ?? []
)
)
}
/**
* Find templates similar to a reference, sorted by descending similarity.
* Excludes the reference template itself (matched by name) and any
* candidates with zero similarity.
*
* @param reference - The template to find similar templates for
* @param candidates - The pool of templates to score against
* @param limit - Maximum number of results to return (default: 10)
* @returns Sorted array of similar templates with their scores
*/
export function findSimilarTemplates<T extends TemplateSimilarityInput>(
reference: TemplateSimilarityInput,
candidates: readonly T[],
limit: number = 10
): SimilarTemplate<T>[] {
return candidates
.filter((c) => c.name !== reference.name)
.map((candidate) => ({
template: candidate,
score: computeSimilarity(reference, candidate)
}))
.filter((result) => result.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}
/**
* Normalize an EnhancedTemplate (or TemplateInfo with category) to
* the shape expected by the similarity scorer.
*
* Wraps the single `category` string into an array and maps
* `requiresCustomNodes` to `requiredNodes`.
*/
export function toSimilarityInput(
template: TemplateInfo & { category?: string }
): TemplateSimilarityInput {
return {
name: template.name,
categories: template.category ? [template.category] : [],
tags: template.tags ?? [],
models: template.models ?? [],
requiredNodes: template.requiresCustomNodes ?? []
}
}