Skip to content

Commit fbdaf5d

Browse files
christian-byrneYourzactions-user
authored
feat: New Template Library (#7062)
## Summary Implement the new design for template library ## Changes - What - New sort option: `Popular` and `Recommended` - New category: `Popular`, leverage the `Popular` sorting - Support add category stick to top of the side bar - Support template customized visible in different platform by `includeOnDistributions` field ### How to make `Popular` and `Recommended` work Add usage-based ordering to workflow templates with position bias correction, manual ranking (searchRank), and freshness boost. New sort modes: - "Recommended" (default): usage × 0.5 + searchRank × 0.3 + freshness × 0.2 - "Popular": usage × 0.9 + freshness × 0.1 ## Screenshots (if applicable) New default ordering: <img width="1812" height="1852" alt="Selection_2485" src="https://github.com/user-attachments/assets/8f4ed6e9-9cf4-43a8-8796-022dcf4c277e" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7062-feat-usage-based-template-ordering-2bb6d73d365081f1ac65f8ad55fe8ce6) by [Unito](https://www.unito.io) Popular category: <img width="281" height="283" alt="image" src="https://github.com/user-attachments/assets/fd54fcb8-6caa-4982-a6b6-1f70ca4b31e3" /> --------- Co-authored-by: Yourz <crazilou@vip.qq.com> Co-authored-by: GitHub Action <action@github.com>
1 parent a7d0825 commit fbdaf5d

File tree

14 files changed

+476
-26
lines changed

14 files changed

+476
-26
lines changed

browser_tests/tests/templates.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ test.describe('Templates', () => {
8383

8484
await comfyPage.page
8585
.locator(
86-
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
86+
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
8787
)
8888
.click()
8989
await comfyPage.templates.loadTemplate('default')

docs/TEMPLATE_RANKING.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Template Ranking System
2+
3+
Usage-based ordering for workflow templates with position bias normalization.
4+
5+
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
6+
7+
## Sort Modes
8+
9+
| Mode | Formula | Description |
10+
| -------------- | ------------------------------------------------ | ---------------------- |
11+
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
12+
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
13+
| `newest` | Date sort | Existing |
14+
| `alphabetical` | Name sort | Existing |
15+
16+
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
17+
18+
## Data Files
19+
20+
**Usage scores** (generated from Mixpanel):
21+
22+
```json
23+
// In templates/index.json, add to any template:
24+
{
25+
"name": "some_template",
26+
"usage": 1000,
27+
...
28+
}
29+
```
30+
31+
**Search rank** (set per-template in workflow_templates repo):
32+
33+
```json
34+
// In templates/index.json, add to any template:
35+
{
36+
"name": "some_template",
37+
"searchRank": 8, // Scale 1-10, default 5
38+
...
39+
}
40+
```
41+
42+
| searchRank | Effect |
43+
| ---------- | ---------------------------- |
44+
| 1-4 | Demote (bury in results) |
45+
| 5 | Neutral (default if not set) |
46+
| 6-10 | Promote (boost in results) |
47+
48+
## Position Bias Correction
49+
50+
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
51+
52+
```
53+
correction = 1 + (position - 1) / (maxPosition - 1)
54+
normalizedUsage = rawUsage × correction
55+
```
56+
57+
| Position | Boost |
58+
| -------- | ----- |
59+
| 1 | 1.0× |
60+
| 50 | 1.28× |
61+
| 100 | 1.57× |
62+
| 175 | 2.0× |
63+
64+
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
65+
66+
---

src/components/custom/widget/WorkflowTemplateSelectorDialog.vue

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
<!-- Actual Template Cards -->
176176
<CardContainer
177177
v-for="template in isLoading ? [] : displayTemplates"
178+
v-show="isTemplateVisibleOnDistribution(template)"
178179
:key="template.name"
179180
ref="cardRefs"
180181
size="compact"
@@ -405,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry'
405406
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
406407
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
407408
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
409+
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
410+
import { useSystemStatsStore } from '@/stores/systemStatsStore'
408411
import type { NavGroupData, NavItemData } from '@/types/navTypes'
409412
import { OnCloseKey } from '@/types/widgetTypes'
410413
import { createGridStyle } from '@/utils/gridUtil'
@@ -423,6 +426,30 @@ onMounted(() => {
423426
sessionStartTime.value = Date.now()
424427
})
425428
429+
const systemStatsStore = useSystemStatsStore()
430+
431+
const distributions = computed(() => {
432+
// eslint-disable-next-line no-undef
433+
switch (__DISTRIBUTION__) {
434+
case 'cloud':
435+
return [TemplateIncludeOnDistributionEnum.Cloud]
436+
case 'localhost':
437+
return [TemplateIncludeOnDistributionEnum.Local]
438+
case 'desktop':
439+
default:
440+
if (systemStatsStore.systemStats?.system.os === 'darwin') {
441+
return [
442+
TemplateIncludeOnDistributionEnum.Desktop,
443+
TemplateIncludeOnDistributionEnum.Mac
444+
]
445+
}
446+
return [
447+
TemplateIncludeOnDistributionEnum.Desktop,
448+
TemplateIncludeOnDistributionEnum.Windows
449+
]
450+
}
451+
})
452+
426453
// Wrap onClose to track session end
427454
const onClose = () => {
428455
if (isCloud) {
@@ -511,6 +538,9 @@ const allTemplates = computed(() => {
511538
return workflowTemplatesStore.enhancedTemplates
512539
})
513540
541+
// Navigation
542+
const selectedNavItem = ref<string | null>('all')
543+
514544
// Filter templates based on selected navigation item
515545
const navigationFilteredTemplates = computed(() => {
516546
if (!selectedNavItem.value) {
@@ -536,6 +566,36 @@ const {
536566
resetFilters
537567
} = useTemplateFiltering(navigationFilteredTemplates)
538568
569+
/**
570+
* Coordinates state between the selected navigation item and the sort order to
571+
* create deterministic, predictable behavior.
572+
* @param source The origin of the change ('nav' or 'sort').
573+
*/
574+
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
575+
const isPopularNav = selectedNavItem.value === 'popular'
576+
const isPopularSort = sortBy.value === 'popular'
577+
578+
if (source === 'nav') {
579+
if (isPopularNav && !isPopularSort) {
580+
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
581+
sortBy.value = 'popular'
582+
} else if (!isPopularNav && isPopularSort) {
583+
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
584+
sortBy.value = 'default'
585+
}
586+
} else if (source === 'sort') {
587+
// When sort is changed away from 'Popular' while in the 'Popular' category,
588+
// reset the category to 'All Templates' to avoid a confusing state.
589+
if (isPopularNav && !isPopularSort) {
590+
selectedNavItem.value = 'all'
591+
}
592+
}
593+
}
594+
595+
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
596+
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
597+
watch(sortBy, () => coordinateNavAndSort('sort'))
598+
539599
// Convert between string array and object array for MultiSelect component
540600
const selectedModelObjects = computed({
541601
get() {
@@ -578,9 +638,6 @@ const cardRefs = ref<HTMLElement[]>([])
578638
// Force re-render key for templates when sorting changes
579639
const templateListKey = ref(0)
580640
581-
// Navigation
582-
const selectedNavItem = ref<string | null>('all')
583-
584641
// Search text for model filter
585642
const modelSearchText = ref<string>('')
586643
@@ -645,11 +702,19 @@ const runsOnFilterLabel = computed(() => {
645702
646703
// Sort options
647704
const sortOptions = computed(() => [
648-
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
649705
{
650706
name: t('templateWorkflows.sort.default', 'Default'),
651707
value: 'default'
652708
},
709+
{
710+
name: t('templateWorkflows.sort.recommended', 'Recommended'),
711+
value: 'recommended'
712+
},
713+
{
714+
name: t('templateWorkflows.sort.popular', 'Popular'),
715+
value: 'popular'
716+
},
717+
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
653718
{
654719
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
655720
value: 'vram-low-to-high'
@@ -750,7 +815,7 @@ const pageTitle = computed(() => {
750815
// Initialize templates loading with useAsyncState
751816
const { isLoading } = useAsyncState(
752817
async () => {
753-
// Run both operations in parallel for better performance
818+
// Run all operations in parallel for better performance
754819
await Promise.all([
755820
loadTemplates(),
756821
workflowTemplatesStore.loadWorkflowTemplates()
@@ -763,6 +828,14 @@ const { isLoading } = useAsyncState(
763828
}
764829
)
765830
831+
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
832+
return (template.includeOnDistributions?.length ?? 0) > 0
833+
? distributions.value.some((d) =>
834+
template.includeOnDistributions?.includes(d)
835+
)
836+
: true
837+
}
838+
766839
onBeforeUnmount(() => {
767840
cardRefs.value = [] // Release DOM refs
768841
})

src/composables/useTemplateFiltering.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createPinia, setActivePinia } from 'pinia'
12
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
23
import { nextTick, ref } from 'vue'
34

@@ -19,10 +20,22 @@ const defaultSettingStore = {
1920
set: vi.fn().mockResolvedValue(undefined)
2021
}
2122

23+
const defaultRankingStore = {
24+
computeDefaultScore: vi.fn(() => 0),
25+
computePopularScore: vi.fn(() => 0),
26+
getUsageScore: vi.fn(() => 0),
27+
computeFreshness: vi.fn(() => 0.5),
28+
isLoaded: { value: false }
29+
}
30+
2231
vi.mock('@/platform/settings/settingStore', () => ({
2332
useSettingStore: vi.fn(() => defaultSettingStore)
2433
}))
2534

35+
vi.mock('@/stores/templateRankingStore', () => ({
36+
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
37+
}))
38+
2639
vi.mock('@/platform/telemetry', () => ({
2740
useTelemetry: vi.fn(() => ({
2841
trackTemplateFilterChanged: vi.fn()
@@ -34,6 +47,7 @@ const { useTemplateFiltering } =
3447

3548
describe('useTemplateFiltering', () => {
3649
beforeEach(() => {
50+
setActivePinia(createPinia())
3751
vi.clearAllMocks()
3852
})
3953

src/composables/useTemplateFiltering.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import type { Ref } from 'vue'
66
import { useSettingStore } from '@/platform/settings/settingStore'
77
import { useTelemetry } from '@/platform/telemetry'
88
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
9+
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
910
import { debounce } from 'es-toolkit/compat'
1011

1112
export function useTemplateFiltering(
1213
templates: Ref<TemplateInfo[]> | TemplateInfo[]
1314
) {
1415
const settingStore = useSettingStore()
16+
const rankingStore = useTemplateRankingStore()
1517

1618
const searchQuery = ref('')
1719
const selectedModels = ref<string[]>(
@@ -25,6 +27,8 @@ export function useTemplateFiltering(
2527
)
2628
const sortBy = ref<
2729
| 'default'
30+
| 'recommended'
31+
| 'popular'
2832
| 'alphabetical'
2933
| 'newest'
3034
| 'vram-low-to-high'
@@ -151,10 +155,42 @@ export function useTemplateFiltering(
151155
return Number.POSITIVE_INFINITY
152156
}
153157

158+
watch(
159+
filteredByRunsOn,
160+
(templates) => {
161+
rankingStore.largestUsageScore = Math.max(
162+
...templates.map((t) => t.usage || 0)
163+
)
164+
},
165+
{ immediate: true }
166+
)
167+
154168
const sortedTemplates = computed(() => {
155169
const templates = [...filteredByRunsOn.value]
156170

157171
switch (sortBy.value) {
172+
case 'recommended':
173+
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
174+
return templates.sort((a, b) => {
175+
const scoreA = rankingStore.computeDefaultScore(
176+
a.date,
177+
a.searchRank,
178+
a.usage
179+
)
180+
const scoreB = rankingStore.computeDefaultScore(
181+
b.date,
182+
b.searchRank,
183+
b.usage
184+
)
185+
return scoreB - scoreA
186+
})
187+
case 'popular':
188+
// User-driven: usage × 0.9 + freshness × 0.1
189+
return templates.sort((a, b) => {
190+
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
191+
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
192+
return scoreB - scoreA
193+
})
158194
case 'alphabetical':
159195
return templates.sort((a, b) => {
160196
const nameA = a.title || a.name || ''
@@ -184,7 +220,7 @@ export function useTemplateFiltering(
184220
return vramA - vramB
185221
})
186222
case 'model-size-low-to-high':
187-
return templates.sort((a: any, b: any) => {
223+
return templates.sort((a, b) => {
188224
const sizeA =
189225
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
190226
const sizeB =
@@ -194,7 +230,6 @@ export function useTemplateFiltering(
194230
})
195231
case 'default':
196232
default:
197-
// Keep original order (default order)
198233
return templates
199234
}
200235
})
@@ -206,7 +241,7 @@ export function useTemplateFiltering(
206241
selectedModels.value = []
207242
selectedUseCases.value = []
208243
selectedRunsOn.value = []
209-
sortBy.value = 'newest'
244+
sortBy.value = 'default'
210245
}
211246

212247
const removeModelFilter = (model: string) => {

src/locales/en/main.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@
873873
"noResultsHint": "Try adjusting your search or filters",
874874
"allTemplates": "All Templates",
875875
"modelFilter": "Model Filter",
876-
"useCaseFilter": "Use Case",
876+
"useCaseFilter": "Tasks",
877877
"licenseFilter": "License",
878878
"modelsSelected": "{count} Models",
879879
"useCasesSelected": "{count} Use Cases",
@@ -882,6 +882,7 @@
882882
"resultsCount": "Showing {count} of {total} templates",
883883
"sort": {
884884
"recommended": "Recommended",
885+
"popular": "Popular",
885886
"alphabetical": "A → Z",
886887
"newest": "Newest",
887888
"searchPlaceholder": "Search...",

src/platform/settings/constants/coreSettings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
10951095
id: 'Comfy.Templates.SortBy',
10961096
name: 'Template library - Sort preference',
10971097
type: 'hidden',
1098-
defaultValue: 'newest'
1098+
defaultValue: 'default'
10991099
},
11001100

11011101
/**

src/platform/telemetry/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
197197
selected_runs_on: string[]
198198
sort_by:
199199
| 'default'
200+
| 'recommended'
201+
| 'popular'
200202
| 'alphabetical'
201203
| 'newest'
202204
| 'vram-low-to-high'

0 commit comments

Comments
 (0)