Skip to content
Merged
2 changes: 1 addition & 1 deletion browser_tests/tests/templates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ test.describe('Templates', () => {

await comfyPage.page
.locator(
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.click()
Comment on lines 84 to 88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

The fragile selector remains unresolved.

Changing nth-child(2) to nth-child(3) fixes the immediate breakage but perpetuates the same maintenance issue. When navigation structure changes again, this test will break again. The previous review already suggested robust alternatives (data-testid, role-based selectors, or scoped getByText), which would prevent this class of breakage.

Based on learnings, prefer specific selectors in browser tests and favor accessible properties over structural selectors like nth-child.

await comfyPage.templates.loadTemplate('default')
Expand Down
66 changes: 66 additions & 0 deletions docs/TEMPLATE_RANKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Template Ranking System

Usage-based ordering for workflow templates with position bias normalization.

Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).

## Sort Modes

| Mode | Formula | Description |
| -------------- | ------------------------------------------------ | ---------------------- |
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
| `newest` | Date sort | Existing |
| `alphabetical` | Name sort | Existing |

Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.

## Data Files

**Usage scores** (generated from Mixpanel):

```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"usage": 1000,
...
}
```

**Search rank** (set per-template in workflow_templates repo):

```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"searchRank": 8, // Scale 1-10, default 5
...
}
```

| searchRank | Effect |
| ---------- | ---------------------------- |
| 1-4 | Demote (bury in results) |
| 5 | Neutral (default if not set) |
| 6-10 | Promote (boost in results) |

## Position Bias Correction

Raw usage reflects true preference AND UI position bias. We use linear interpolation:

```
correction = 1 + (position - 1) / (maxPosition - 1)
normalizedUsage = rawUsage × correction
```

| Position | Boost |
| -------- | ----- |
| 1 | 1.0× |
| 50 | 1.28× |
| 100 | 1.57× |
| 175 | 2.0× |

Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.

---
83 changes: 78 additions & 5 deletions src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
Expand Down Expand Up @@ -405,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
Expand All @@ -423,6 +426,30 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})

const systemStatsStore = useSystemStatsStore()

const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
Comment on lines +429 to +451
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find the useSystemStatsStore implementation
find src -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs rg -l "useSystemStatsStore|export.*systemStatsStore" | head -10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 438


🏁 Script executed:

# Check the component file around the lines in question
cat -n src/components/custom/widget/WorkflowTemplateSelectorDialog.vue | sed -n '420,460p'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1432


🏁 Script executed:

# Search for where systemStats is loaded/initialized in the store
rg -nP "systemStats\s*=|systemStats\s*\:" src --type=ts --type=tsx -A 3 | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 97


🏁 Script executed:

# Search for initialization of systemStats in stores
fd -e ts -e tsx -e vue src | xargs rg -l "systemStats" | grep -E "store|composable"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 608


🏁 Script executed:

# Examine the systemStatsStore implementation
cat src/stores/systemStatsStore.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1736


🏁 Script executed:

# Check the test file for usage patterns and expected behavior
cat tests-ui/tests/store/systemStatsStore.test.ts

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 10381


🏁 Script executed:

# Search for where systemStatsStore is initialized at app startup
rg -nP "useSystemStatsStore|systemStatsStore" src/main.ts src/App.vue src/stores --type=ts -B 2 -A 5

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1823


Handle systemStats loading state to avoid incorrect distribution filtering on macOS.

The distributions computed property defaults to [Desktop, Windows] when systemStats is undefined. Since useSystemStatsStore() fetches data asynchronously after initialization, macOS users on desktop may see incorrect templates until the stats load.

Add a guard using the store's isInitialized flag, or consider using the store's existing getFormFactor() method which properly handles the loading state by returning 'other' until data is available:

const distributions = computed(() => {
  switch (__DISTRIBUTION__) {
    case 'cloud':
      return [TemplateIncludeOnDistributionEnum.Cloud]
    case 'localhost':
      return [TemplateIncludeOnDistributionEnum.Local]
    case 'desktop':
    default:
      if (!systemStatsStore.isInitialized) {
        return [TemplateIncludeOnDistributionEnum.Desktop]
      }
      if (systemStatsStore.systemStats?.system.os === 'darwin') {
        return [
          TemplateIncludeOnDistributionEnum.Desktop,
          TemplateIncludeOnDistributionEnum.Mac
        ]
      }
      return [
        TemplateIncludeOnDistributionEnum.Desktop,
        TemplateIncludeOnDistributionEnum.Windows
      ]
  }
})
🤖 Prompt for AI Agents
In src/components/custom/widget/WorkflowTemplateSelectorDialog.vue around lines
429 to 451, the distributions computed currently defaults to [Desktop, Windows]
when systemStats is undefined which can show incorrect templates on macOS while
stats load; update the logic to guard the desktop branch by checking
systemStatsStore.isInitialized (or call systemStatsStore.getFormFactor()) and
return only [TemplateIncludeOnDistributionEnum.Desktop] while not initialized,
and once initialized use systemStatsStore.systemStats?.system.os === 'darwin' to
choose [Desktop, Mac] otherwise [Desktop, Windows].


// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
Expand Down Expand Up @@ -511,6 +538,9 @@ const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})

// Navigation
const selectedNavItem = ref<string | null>('all')

// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
Expand All @@ -536,6 +566,36 @@ const {
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)

/**
* Coordinates state between the selected navigation item and the sort order to
* create deterministic, predictable behavior.
* @param source The origin of the change ('nav' or 'sort').
*/
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
const isPopularNav = selectedNavItem.value === 'popular'
const isPopularSort = sortBy.value === 'popular'

if (source === 'nav') {
if (isPopularNav && !isPopularSort) {
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
sortBy.value = 'popular'
} else if (!isPopularNav && isPopularSort) {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort') {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
}
}

// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
watch(sortBy, () => coordinateNavAndSort('sort'))

// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
Expand Down Expand Up @@ -578,9 +638,6 @@ const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

// Navigation
const selectedNavItem = ref<string | null>('all')

// Search text for model filter
const modelSearchText = ref<string>('')

Expand Down Expand Up @@ -645,11 +702,19 @@ const runsOnFilterLabel = computed(() => {

// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
Expand Down Expand Up @@ -750,7 +815,7 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run both operations in parallel for better performance
// Run all operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
Expand All @@ -763,6 +828,14 @@ const { isLoading } = useAsyncState(
}
)

const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
return (template.includeOnDistributions?.length ?? 0) > 0
? distributions.value.some((d) =>
template.includeOnDistributions?.includes(d)
)
: true
}

onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
Expand Down
14 changes: 14 additions & 0 deletions src/composables/useTemplateFiltering.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'

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

const defaultRankingStore = {
computeDefaultScore: vi.fn(() => 0),
computePopularScore: vi.fn(() => 0),
getUsageScore: vi.fn(() => 0),
computeFreshness: vi.fn(() => 0.5),
isLoaded: { value: false }
}
Comment on lines +23 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding test coverage for 'recommended' and 'popular' sort modes.

The mock always returns constant values (0 for scores, 0.5 for freshness), which means templates will have identical scores. While this works for basic testing, there's no dedicated test coverage verifying the 'recommended' and 'popular' sorting behavior actually uses the ranking store correctly.

Consider adding tests that configure the mock to return different scores for different templates to verify sorting order.

🤖 Prompt for AI Agents
In @src/composables/useTemplateFiltering.test.ts around lines 23 - 29, The tests
currently mock defaultRankingStore with identical scores so sorting behavior for
'recommended' and 'popular' isn't validated; update the test suite in
useTemplateFiltering.test.ts to add cases that set computeDefaultScore and
computePopularScore (and/or getUsageScore/computeFreshness) to return different
values per template, then assert that sortTemplates (or the hook using sortMode
'recommended' and 'popular') orders templates accordingly; target the
defaultRankingStore mock object and the functions computeDefaultScore,
computePopularScore, getUsageScore, and computeFreshness to simulate distinct
scores and add assertions for expected ordering.


vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))

vi.mock('@/stores/templateRankingStore', () => ({
useTemplateRankingStore: vi.fn(() => defaultRankingStore)
}))

vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackTemplateFilterChanged: vi.fn()
Expand All @@ -34,6 +47,7 @@ const { useTemplateFiltering } =

describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

Expand Down
41 changes: 38 additions & 3 deletions src/composables/useTemplateFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ 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 { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'

export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const settingStore = useSettingStore()
const rankingStore = useTemplateRankingStore()

const searchQuery = ref('')
const selectedModels = ref<string[]>(
Expand All @@ -25,6 +27,8 @@ export function useTemplateFiltering(
)
const sortBy = ref<
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
Expand Down Expand Up @@ -151,10 +155,42 @@ export function useTemplateFiltering(
return Number.POSITIVE_INFINITY
}

watch(
filteredByRunsOn,
(templates) => {
rankingStore.largestUsageScore = Math.max(
...templates.map((t) => t.usage || 0)
)
},
{ immediate: true }
)

const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]

switch (sortBy.value) {
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const scoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const scoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
return scoreB - scoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
Expand Down Expand Up @@ -184,7 +220,7 @@ export function useTemplateFiltering(
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a: any, b: any) => {
return templates.sort((a, b) => {
const sizeA =
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
Expand All @@ -194,7 +230,6 @@ export function useTemplateFiltering(
})
case 'default':
default:
// Keep original order (default order)
return templates
}
})
Expand All @@ -206,7 +241,7 @@ export function useTemplateFiltering(
selectedModels.value = []
selectedUseCases.value = []
selectedRunsOn.value = []
sortBy.value = 'newest'
sortBy.value = 'default'
}

const removeModelFilter = (model: string) => {
Expand Down
3 changes: 2 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,7 @@
"noResultsHint": "Try adjusting your search or filters",
"allTemplates": "All Templates",
"modelFilter": "Model Filter",
"useCaseFilter": "Use Case",
"useCaseFilter": "Tasks",
"licenseFilter": "License",
"modelsSelected": "{count} Models",
"useCasesSelected": "{count} Use Cases",
Expand All @@ -882,6 +882,7 @@
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
"popular": "Popular",
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",
Expand Down
2 changes: 1 addition & 1 deletion src/platform/settings/constants/coreSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Templates.SortBy',
name: 'Template library - Sort preference',
type: 'hidden',
defaultValue: 'newest'
defaultValue: 'default'
},

/**
Expand Down
2 changes: 2 additions & 0 deletions src/platform/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export interface TemplateFilterMetadata {
selected_runs_on: string[]
sort_by:
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
Expand Down
Loading