From 9fab901a84e45f75df5eaea4c6d0a88b847c9e1a Mon Sep 17 00:00:00 2001 From: bymyself Date: Sun, 30 Nov 2025 13:54:39 -0800 Subject: [PATCH] feat: usage-based template ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Includes generation script for refreshing scores from Mixpanel data. --- docs/TEMPLATE_RANKING.md | 95 +++++ package.json | 1 + public/assets/template-usage-scores.json | 188 ++++++++++ scripts/generate-template-scores.ts | 326 ++++++++++++++++++ .../widget/WorkflowTemplateSelectorDialog.vue | 15 +- src/composables/useTemplateFiltering.ts | 31 +- src/locales/en/main.json | 3 +- .../settings/constants/coreSettings.ts | 2 +- src/platform/telemetry/types.ts | 1 + .../workflow/templates/types/template.ts | 5 + src/schemas/apiSchema.ts | 1 + src/stores/templateRankingStore.ts | 87 +++++ tests-ui/stores/templateRankingStore.test.ts | 136 ++++++++ 13 files changed, 881 insertions(+), 10 deletions(-) create mode 100644 docs/TEMPLATE_RANKING.md create mode 100644 public/assets/template-usage-scores.json create mode 100644 scripts/generate-template-scores.ts create mode 100644 src/stores/templateRankingStore.ts create mode 100644 tests-ui/stores/templateRankingStore.test.ts diff --git a/docs/TEMPLATE_RANKING.md b/docs/TEMPLATE_RANKING.md new file mode 100644 index 0000000000..521348ae43 --- /dev/null +++ b/docs/TEMPLATE_RANKING.md @@ -0,0 +1,95 @@ +# 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 | +| -------------- | ------------------------------------------------ | ---------------------- | +| `default` | `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): + +``` +public/assets/template-usage-scores.json # { "template_name": 0.95, ... } normalized 0-1 +``` + +**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. + +--- + +## Updating Scores + +```bash +# 1. Export from Mixpanel https://mixpanel.com/s/21GKgr (export as CSV) +# 2. Run script +pnpm generate:template-scores --input ./mixpanel-export.csv + +# 3. Commit +git add public/assets/template-usage-scores.json +git commit -m "[feat] Update template ranking scores" +``` + +**Script options:** + +- `--input, -i` — Mixpanel CSV (required) +- `--ui-order, -u` — templates index.json path (default: fetches from repo) +- `--output, -o` — output dir (default: `public/assets/`) + +**Expected CSV format:** + +```csv +template_name,count +01_qwen_t2i_subgraphed,1085 +video_wan2_2_14B_animate,713 +``` + +**Manual ranking adjustments:** Set `searchRank` on templates in `workflow_templates` repo's `index.json`: + +- `1-4` = demote (bury in results) +- `5` = neutral (default) +- `6-10` = promote (boost in results) + +**Update frequency:** Monthly, or after major template additions. diff --git a/package.json b/package.json index 3bdff93157..a0644617b9 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache", "format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different", "format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different", + "generate:template-scores": "tsx scripts/generate-template-scores.ts", "json-schema": "tsx scripts/generate-json-schema.ts", "knip:no-cache": "knip", "knip": "knip --cache", diff --git a/public/assets/template-usage-scores.json b/public/assets/template-usage-scores.json new file mode 100644 index 0000000000..0abdd05d8e --- /dev/null +++ b/public/assets/template-usage-scores.json @@ -0,0 +1,188 @@ +{ + "03_video_wan2_2_14B_i2v_subgraphed": 1, + "01_qwen_t2i_subgraphed": 0.9588, + "api_nano_banana_pro": 0.9253, + "video_wan2_2_14B_animate": 0.6013, + "02_qwen_Image_edit_subgraphed": 0.5489, + "video_wan2_2_14B_i2v": 0.4694, + "image_flux2": 0.4575, + "api_openai_sora_video": 0.4499, + "api_ltxv_image_to_video": 0.4341, + "image_z_image_turbo": 0.3946, + "image_chrono_edit_14B": 0.377, + "image_qwen_image_edit_2509": 0.358, + "api_wan_image_to_video": 0.3453, + "image_netayume_lumina_t2i": 0.3248, + "api_ltxv_text_to_video": 0.291, + "video_hunyuan_video_1.5_720p_i2v": 0.2876, + "video_wan2_2_14B_t2v": 0.2803, + "04_hunyuan_3d_2.1_subgraphed": 0.2783, + "api_google_gemini_image": 0.2717, + "api_flux2": 0.2685, + "flux1_krea_dev": 0.2634, + "video_wan2.1_alpha_t2v_14B": 0.2341, + "flux1_dev_uso_reference_image_gen": 0.2213, + "api_wan_text_to_video": 0.2145, + "api_bytedance_image_to_video": 0.2077, + "image_flux2_fp8": 0.2044, + "video_humo": 0.2024, + "api_bytedance_seedream4": 0.1892, + "3d_hunyuan3d-v2.1": 0.1843, + "api_from_photo_2_miniature": 0.1792, + "api_wan_text_to_image": 0.1714, + "video_wan2_2_14B_flf2v": 0.1668, + "api_topaz_video_enhance": 0.1555, + "video_hunyuan_video_1.5_720p_t2v": 0.1544, + "hiresfix_latent_workflow": 0.1538, + "api_veo3": 0.1503, + "video_wan2_2_14B_s2v": 0.1496, + "video_wan2_2_14B_fun_camera": 0.1404, + "api_bytedance_flf2v": 0.1338, + "video_wan2_2_14B_fun_inpaint": 0.1288, + "video_wan2_2_5B_ti2v": 0.1286, + "image_qwen_image": 0.1263, + "image_qwen_image_instantx_controlnet": 0.1263, + "video_wan2_2_14B_fun_control": 0.1247, + "image_chroma1_radiance_text_to_image": 0.1238, + "image_qwen_image_instantx_inpainting_controlnet": 0.1215, + "image_flux.1_fill_dev_OneReward": 0.1178, + "05_audio_ace_step_1_t2a_song_subgraphed": 0.1117, + "api_topaz_image_enhance": 0.1109, + "flux_kontext_dev_basic": 0.109, + "api_bytedance_text_to_video": 0.1055, + "video_wan_vace_14B_v2v": 0.1028, + "api_rodin_gen2": 0.1015, + "video_wan_vace_14B_ref2v": 0.1009, + "esrgan_example": 0.0933, + "hidream_e1_1": 0.09, + "audio_stable_audio_example": 0.0884, + "ltxv_image_to_video": 0.0883, + "default": 0.0869, + "image_qwen_image_edit": 0.0849, + "flux_dev_checkpoint_example": 0.0838, + "video_wan2_2_5B_fun_control": 0.0821, + "image_qwen_image_controlnet_patch": 0.0806, + "hiresfix_esrgan_workflow": 0.0799, + "image_qwen_image_union_control_lora": 0.0782, + "latent_upscale_different_prompt_model": 0.0771, + "flux_fill_inpaint_example": 0.077, + "lora": 0.0762, + "sdxl_simple_example": 0.0727, + "image2image": 0.0724, + "api_google_gemini": 0.0698, + "api_kling_i2v": 0.0698, + "flux_dev_full_text_to_image": 0.0677, + "api_openai_chat": 0.0634, + "sd3.5_simple_example": 0.0621, + "hidream_i1_full": 0.062, + "hunyuan_video_text_to_video": 0.0611, + "video_wan_vace_inpainting": 0.0591, + "3d_hunyuan3d_multiview_to_model": 0.0566, + "image_to_video_wan": 0.0532, + "api_bfl_flux_1_kontext_pro_image": 0.0514, + "2_pass_pose_worship": 0.0509, + "sdxlturbo_example": 0.05, + "api_vidu_reference_to_video": 0.0492, + "image_to_video": 0.0469, + "flux_redux_model_example": 0.0451, + "image_chroma_text_to_image": 0.0451, + "video_wan_vace_flf2v": 0.045, + "flux_fill_outpaint_example": 0.0434, + "api_bfl_flux_1_kontext_multiple_images_input": 0.0422, + "audio_ace_step_1_t2a_song": 0.0422, + "video_wan_vace_14B_t2v": 0.0412, + "wan2.1_fun_control": 0.0412, + "flux_depth_lora_example": 0.0402, + "image_omnigen2_t2i": 0.0397, + "api_runway_gen4_turo_image_to_video": 0.0396, + "video_wan_ati": 0.0392, + "api_bfl_flux_pro_t2i": 0.0383, + "3d_hunyuan3d_image_to_model": 0.0381, + "api_vidu_image_to_video": 0.0369, + "sdxl_refiner_prompt_example": 0.036, + "api_stability_ai_text_to_audio": 0.0358, + "api_kling_flf": 0.0346, + "hidream_i1_dev": 0.0344, + "lora_multiple": 0.0341, + "video_wan2_2_5B_fun_inpaint": 0.0339, + "3d_hunyuan3d_multiview_to_model_turbo": 0.0324, + "depth_t2i_adapter": 0.032, + "video_wan_vace_outpainting": 0.0317, + "ltxv_text_to_video": 0.0313, + "hidream_e1_full": 0.0308, + "audio_ace_step_1_m2m_editing": 0.0306, + "image_lotus_depth_v1_1": 0.0305, + "api_bfl_flux_1_kontext_max_image": 0.0304, + "audio_ace_step_1_t2a_instrumentals": 0.0303, + "sd3.5_large_canny_controlnet_example": 0.03, + "video_wan2.1_fun_camera_v1.1_14B": 0.0299, + "depth_controlnet": 0.0294, + "flux_canny_model_example": 0.0292, + "api_stability_ai_i2i": 0.0286, + "sd3.5_large_depth": 0.0282, + "sdxl_revision_text_prompts": 0.0282, + "api_vidu_start_end_to_video": 0.0276, + "image_omnigen2_image_edit": 0.0263, + "inpaint_example": 0.0252, + "inpaint_model_outpainting": 0.0246, + "api_moonvalley_video_to_video_motion_transfer": 0.0243, + "wan2.1_flf2v_720_f16": 0.0233, + "api_runway_text_to_image": 0.023, + "mixing_controlnets": 0.023, + "api_rodin_image_to_model": 0.0227, + "txt_to_image_to_video": 0.0227, + "api_runway_reference_to_image": 0.0226, + "api_vidu_text_to_video": 0.0226, + "api_stability_ai_audio_to_audio": 0.0222, + "api_tripo_multiview_to_model": 0.0221, + "api_luma_photon_i2i": 0.0218, + "api_openai_image_1_i2i": 0.0216, + "api_veo2_i2v": 0.0216, + "api_moonvalley_image_to_video": 0.0215, + "text_to_video_wan": 0.0206, + "api_tripo_image_to_model": 0.0197, + "wan2.1_fun_inp": 0.0195, + "api_runway_first_last_frame": 0.0194, + "api_moonvalley_video_to_video_pose_control": 0.0192, + "api_openai_dall_e_3_t2i": 0.0192, + "video_wan2.1_fun_camera_v1.1_1.3B": 0.0189, + "api_tripo_text_to_model": 0.0179, + "api_luma_i2v": 0.0175, + "flux_schnell": 0.0175, + "api_kling_effects": 0.0172, + "flux_schnell_full_text_to_image": 0.0168, + "api_stability_ai_sd3.5_i2i": 0.0164, + "hidream_i1_fast": 0.0163, + "mochi_text_to_video_example": 0.0159, + "api_hailuo_minimax_i2v": 0.0158, + "api_recraft_vector_gen": 0.0149, + "embedding_example": 0.0143, + "api_runway_gen3a_turbo_image_to_video": 0.014, + "api_luma_photon_style_ref": 0.0137, + "sd3.5_large_blur": 0.0136, + "api_rodin_multiview_to_model": 0.0135, + "api_stability_ai_stable_image_ultra_t2i": 0.013, + "controlnet_example": 0.0128, + "gligen_textbox_example": 0.0125, + "api_recraft_image_gen_with_style_control": 0.0114, + "api_pika_scene": 0.0105, + "api_pixverse_i2v": 0.0105, + "api_luma_t2v": 0.0103, + "api_openai_image_1_inpaint": 0.0103, + "api_pika_i2v": 0.0102, + "api_stability_ai_audio_inpaint": 0.01, + "api_ideogram_v3_t2i": 0.0097, + "api_stability_ai_sd3.5_t2i": 0.0094, + "api_hailuo_minimax_t2v": 0.0091, + "api_openai_image_1_multi_inputs": 0.0091, + "area_composition": 0.0086, + "api_hailuo_minimax_video": 0.008, + "api_recraft_image_gen_with_color_control": 0.0077, + "api_openai_dall_e_2_inpaint": 0.0076, + "api_pixverse_t2v": 0.0073, + "api_openai_image_1_t2i": 0.007, + "api_moonvalley_text_to_video": 0.0068, + "api_pixverse_template_i2v": 0.0064, + "area_composition_square_area_for_subject": 0.0055, + "api_openai_dall_e_2_t2i": 0.0041 +} \ No newline at end of file diff --git a/scripts/generate-template-scores.ts b/scripts/generate-template-scores.ts new file mode 100644 index 0000000000..f9028affb0 --- /dev/null +++ b/scripts/generate-template-scores.ts @@ -0,0 +1,326 @@ +/* eslint-disable no-console */ +/** + * Generate template ranking scores from Mixpanel usage data. + * + * Usage: + * pnpm generate:template-scores --input ./mixpanel-export.csv + * + * See docs/TEMPLATE_RANKING.md for full documentation. + */ + +import fs from 'fs' +import path from 'path' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TemplateInfo { + name: string + [key: string]: unknown +} + +interface WorkflowTemplates { + templates: TemplateInfo[] + [key: string]: unknown +} + +// Index can be either array of modules or object with modules property +type TemplatesIndex = WorkflowTemplates[] | { modules: WorkflowTemplates[] } + +interface RawUsageData { + templateName: string + count: number +} + +interface Config { + inputPath: string + uiOrderPath: string | null + outputDir: string + dryRun: boolean +} + +// --------------------------------------------------------------------------- +// CLI Argument Parsing +// --------------------------------------------------------------------------- + +function parseArgs(): Config { + const args = process.argv.slice(2) + const config: Config = { + inputPath: '', + uiOrderPath: null, + outputDir: './public/assets', + dryRun: false + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + switch (arg) { + case '--input': + case '-i': + config.inputPath = args[++i] + break + case '--ui-order': + case '-u': + config.uiOrderPath = args[++i] + break + case '--output': + case '-o': + config.outputDir = args[++i] + break + case '--dry-run': + config.dryRun = true + break + case '--help': + case '-h': + printHelp() + process.exit(0) + } + } + + if (!config.inputPath) { + console.error('Error: --input is required') + printHelp() + process.exit(1) + } + + return config +} + +function printHelp(): void { + console.log(` +Usage: pnpm generate:template-scores --input [options] + +Options: + --input, -i Path to Mixpanel CSV export (required) + --ui-order, -u Path to templates index.json (default: fetch from GitHub) + --output, -o Output directory (default: ./public/assets) + --dry-run Print scores without writing files + --help, -h Show this help message + +Example: + pnpm generate:template-scores -i ./mixpanel-export.csv + pnpm generate:template-scores -i ./data.csv -u ./index.json --dry-run +`) +} + +// --------------------------------------------------------------------------- +// Data Loading +// --------------------------------------------------------------------------- + +function parseCSV(content: string): RawUsageData[] { + const lines = content.trim().split('\n') + if (lines.length < 2) { + throw new Error('CSV must have header row and at least one data row') + } + + // Detect column layout from header + const header = lines[0].toLowerCase() + const headerParts = parseCSVLine(header) + + // Find template name column (workflow_name or template_name) + let nameColIndex = headerParts.findIndex( + (h) => + h.includes('workflow_name') || + h.includes('template_name') || + h.includes('templatename') + ) + // If no name column found, assume first column (simple 2-column format) + if (nameColIndex === -1) nameColIndex = 0 + + const results: RawUsageData[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim() + if (!line) continue + + const parts = parseCSVLine(line) + if (parts.length < 2) continue + + const templateName = parts[nameColIndex]?.replace(/^["']|["']$/g, '').trim() + const countStr = parts[parts.length - 1].replace(/^["']|["']$/g, '').trim() + const count = parseInt(countStr, 10) + + if (templateName && !isNaN(count) && count > 0) { + results.push({ templateName, count }) + } + } + + if (results.length === 0) { + throw new Error('No valid data rows found in CSV') + } + + console.log(`Parsed ${results.length} templates from CSV`) + return results +} + +function parseCSVLine(line: string): string[] { + const result: string[] = [] + let current = '' + let inQuotes = false + + for (const char of line) { + if (char === '"' && !inQuotes) { + inQuotes = true + } else if (char === '"' && inQuotes) { + inQuotes = false + } else if (char === ',' && !inQuotes) { + result.push(current.trim()) + current = '' + } else { + current += char + } + } + result.push(current.trim()) + return result +} + +async function loadUIOrder(uiOrderPath: string | null): Promise { + let content: string + + if (uiOrderPath) { + content = fs.readFileSync(uiOrderPath, 'utf-8') + } else { + console.log('Fetching templates index from GitHub...') + const url = + 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/index.json' + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch index.json: ${response.status}`) + } + content = await response.text() + } + + const parsed = JSON.parse(content) + // Handle both array format and { modules: [...] } format + const modules: WorkflowTemplates[] = Array.isArray(parsed) + ? parsed + : parsed.modules + + const order: string[] = [] + for (const module of modules) { + for (const template of module.templates) { + order.push(template.name) + } + } + + console.log(`Loaded ${order.length} templates from UI order`) + return order +} + +// --------------------------------------------------------------------------- +// Score Computation +// --------------------------------------------------------------------------- + +function computePositionCorrection( + position: number, + maxPosition: number +): number { + // Linear interpolation: position 1 = 1.0×, position max = 2.0× + // Buried templates get proportionally stronger boost + return 1 + (position - 1) / (maxPosition - 1) +} + +function computeNormalizedScores( + rawData: RawUsageData[], + uiOrder: string[] +): Record { + // Build position lookup + const positionMap = new Map() + uiOrder.forEach((name, index) => positionMap.set(name, index + 1)) + + const maxPosition = uiOrder.length + const medianPosition = Math.floor(maxPosition / 2) + + // Apply position correction + const correctedScores: Array<{ name: string; score: number }> = [] + for (const { templateName, count } of rawData) { + const position = positionMap.get(templateName) ?? medianPosition + const correction = computePositionCorrection(position, maxPosition) + correctedScores.push({ + name: templateName, + score: count * correction + }) + } + + // Normalize to 0-1 + const maxScore = Math.max(...correctedScores.map((s) => s.score)) + const normalized: Record = {} + + for (const { name, score } of correctedScores) { + // Round to 4 decimal places for cleaner output + normalized[name] = Math.round((score / maxScore) * 10000) / 10000 + } + + // Sort by score descending + const sorted = Object.entries(normalized).sort(([, a], [, b]) => b - a) + return Object.fromEntries(sorted) +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function writeOutput( + outputDir: string, + usageScores: Record, + dryRun: boolean +): void { + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + const usageScoresPath = path.join(outputDir, 'template-usage-scores.json') + + if (dryRun) { + console.log('\n--- DRY RUN: Would write to', usageScoresPath, '---') + const entries = Object.entries(usageScores).slice(0, 20) + for (const [name, score] of entries) { + console.log(` ${name}: ${score}`) + } + console.log(` ... and ${Object.keys(usageScores).length - 20} more`) + return + } + + fs.writeFileSync(usageScoresPath, JSON.stringify(usageScores, null, 2)) + console.log( + `Wrote ${Object.keys(usageScores).length} scores to ${usageScoresPath}` + ) +} + +function printSummary(usageScores: Record): void { + const entries = Object.entries(usageScores) + console.log('\n=== Summary ===') + console.log(`Total templates scored: ${entries.length}`) + console.log('\nTop 10 by usage score:') + entries.slice(0, 10).forEach(([name, score], i) => { + console.log(` ${i + 1}. ${name}: ${score}`) + }) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const config = parseArgs() + + // Load data + const csvContent = fs.readFileSync(config.inputPath, 'utf-8') + const rawData = parseCSV(csvContent) + const uiOrder = await loadUIOrder(config.uiOrderPath) + + // Compute scores + const usageScores = computeNormalizedScores(rawData, uiOrder) + + // Output + writeOutput(config.outputDir, usageScores, config.dryRun) + printSummary(usageScores) + + console.log('\nDone!') +} + +main().catch((err) => { + console.error('Error:', err.message) + process.exit(1) +}) diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index a1174aa100..d31dacaff2 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -405,6 +405,7 @@ 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 { useTemplateRankingStore } from '@/stores/templateRankingStore' import type { NavGroupData, NavItemData } from '@/types/navTypes' import { OnCloseKey } from '@/types/widgetTypes' import { createGridStyle } from '@/utils/gridUtil' @@ -443,6 +444,7 @@ provide(OnCloseKey, onClose) // Workflow templates store and composable const workflowTemplatesStore = useWorkflowTemplatesStore() +const templateRankingStore = useTemplateRankingStore() const { loadTemplates, loadWorkflowTemplate, @@ -645,11 +647,15 @@ const runsOnFilterLabel = computed(() => { // Sort options const sortOptions = computed(() => [ - { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { - name: t('templateWorkflows.sort.default', 'Default'), + name: t('templateWorkflows.sort.default', 'Recommended'), value: 'default' }, + { + 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' @@ -750,10 +756,11 @@ 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() + workflowTemplatesStore.loadWorkflowTemplates(), + templateRankingStore.loadScores() ]) return true }, diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index c181e2f917..b99157af82 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -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[] ) { const settingStore = useSettingStore() + const rankingStore = useTemplateRankingStore() const searchQuery = ref('') const selectedModels = ref( @@ -25,6 +27,7 @@ export function useTemplateFiltering( ) const sortBy = ref< | 'default' + | 'popular' | 'alphabetical' | 'newest' | 'vram-low-to-high' @@ -155,6 +158,28 @@ export function useTemplateFiltering( const templates = [...filteredByRunsOn.value] switch (sortBy.value) { + case 'default': + // Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2 + return templates.sort((a, b) => { + const scoreA = rankingStore.computeDefaultScore( + a.name, + a.date, + a.searchRank + ) + const scoreB = rankingStore.computeDefaultScore( + b.name, + b.date, + b.searchRank + ) + return scoreB - scoreA + }) + case 'popular': + // User-driven: usage × 0.9 + freshness × 0.1 + return templates.sort((a, b) => { + const scoreA = rankingStore.computePopularScore(a.name, a.date) + const scoreB = rankingStore.computePopularScore(b.name, b.date) + return scoreB - scoreA + }) case 'alphabetical': return templates.sort((a, b) => { const nameA = a.title || a.name || '' @@ -184,7 +209,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 = @@ -192,9 +217,7 @@ export function useTemplateFiltering( if (sizeA === sizeB) return 0 return sizeA - sizeB }) - case 'default': default: - // Keep original order (default order) return templates } }) @@ -206,7 +229,7 @@ export function useTemplateFiltering( selectedModels.value = [] selectedUseCases.value = [] selectedRunsOn.value = [] - sortBy.value = 'newest' + sortBy.value = 'default' } const removeModelFilter = (model: string) => { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 13638131f6..d914b15f9b 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -850,12 +850,13 @@ "resultsCount": "Showing {count} of {total} templates", "sort": { "recommended": "Recommended", + "popular": "Popular", "alphabetical": "A → Z", "newest": "Newest", "searchPlaceholder": "Search...", "vramLowToHigh": "VRAM Usage (Low to High)", "modelSizeLowToHigh": "Model Size (Low to High)", - "default": "Default" + "default": "Recommended" }, "error": { "templateNotFound": "Template \"{templateName}\" not found" diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 7590c0f88f..c95f9a171b 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -1080,7 +1080,7 @@ export const CORE_SETTINGS: SettingParams[] = [ id: 'Comfy.Templates.SortBy', name: 'Template library - Sort preference', type: 'hidden', - defaultValue: 'newest' + defaultValue: 'default' }, /** diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 5b5e0f9fc6..f0b39b1582 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -195,6 +195,7 @@ export interface TemplateFilterMetadata { selected_runs_on: string[] sort_by: | 'default' + | 'popular' | 'alphabetical' | 'newest' | 'vram-low-to-high' diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts index 25a209e854..6c305bb3ff 100644 --- a/src/platform/workflow/templates/types/template.ts +++ b/src/platform/workflow/templates/types/template.ts @@ -32,6 +32,11 @@ export interface TemplateInfo { * Templates with this field will be hidden on local installations temporarily. */ requiresCustomNodes?: string[] + /** + * Manual ranking boost/demotion for "Recommended" sort. Scale 1-10, default 5. + * Higher values promote the template, lower values demote it. + */ + searchRank?: number } export interface WorkflowTemplates { diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 653dbcf5d6..333a58f0cf 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -513,6 +513,7 @@ const zSettings = z.object({ 'Comfy.Templates.SelectedRunsOn': z.array(z.string()), 'Comfy.Templates.SortBy': z.enum([ 'default', + 'popular', 'alphabetical', 'newest', 'vram-low-to-high', diff --git a/src/stores/templateRankingStore.ts b/src/stores/templateRankingStore.ts new file mode 100644 index 0000000000..0d4ab70d28 --- /dev/null +++ b/src/stores/templateRankingStore.ts @@ -0,0 +1,87 @@ +/** + * Store for template ranking scores. + * Loads pre-computed usage scores from static JSON. + * Internal ranks come from template.searchRank in index.json. + * See docs/TEMPLATE_RANKING.md for details. + */ + +import axios from 'axios' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useTemplateRankingStore = defineStore('templateRanking', () => { + const usageScores = ref>({}) + const isLoaded = ref(false) + + const loadScores = async (): Promise => { + if (isLoaded.value) return + + try { + const response = await axios.get('assets/template-usage-scores.json') + usageScores.value = response.data + isLoaded.value = true + } catch (error) { + console.error('Error loading template ranking scores:', error) + } + } + + /** + * Get normalized usage score (0-1) for a template. + */ + const getUsageScore = (templateName: string): number => { + return usageScores.value[templateName] ?? 0 + } + + /** + * Compute freshness score based on template date. + * Returns 1.0 for brand new, decays to 0.1 over ~6 months. + */ + const computeFreshness = (dateStr: string | undefined): number => { + if (!dateStr) return 0.5 // Default for templates without dates + + const date = new Date(dateStr) + if (isNaN(date.getTime())) return 0.5 + + const daysSinceAdded = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24) + return Math.max(0.1, 1.0 / (1 + daysSinceAdded / 90)) + } + + /** + * Compute composite score for "default" sort. + * Formula: usage × 0.5 + internal × 0.3 + freshness × 0.2 + */ + const computeDefaultScore = ( + templateName: string, + dateStr: string | undefined, + searchRank: number | undefined + ): number => { + const usage = getUsageScore(templateName) + const internal = (searchRank ?? 5) / 10 // Normalize 1-10 to 0-1 + const freshness = computeFreshness(dateStr) + + return usage * 0.5 + internal * 0.3 + freshness * 0.2 + } + + /** + * Compute composite score for "popular" sort. + * Formula: usage × 0.9 + freshness × 0.1 + */ + const computePopularScore = ( + templateName: string, + dateStr: string | undefined + ): number => { + const usage = getUsageScore(templateName) + const freshness = computeFreshness(dateStr) + + return usage * 0.9 + freshness * 0.1 + } + + return { + isLoaded, + loadScores, + getUsageScore, + computeFreshness, + computeDefaultScore, + computePopularScore + } +}) diff --git a/tests-ui/stores/templateRankingStore.test.ts b/tests-ui/stores/templateRankingStore.test.ts new file mode 100644 index 0000000000..ee5b56f93f --- /dev/null +++ b/tests-ui/stores/templateRankingStore.test.ts @@ -0,0 +1,136 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useTemplateRankingStore } from '@/stores/templateRankingStore' + +// Mock axios +vi.mock('axios', () => ({ + default: { + get: vi.fn() + } +})) + +describe('templateRankingStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe('computeFreshness', () => { + it('returns 1.0 for brand new template (today)', () => { + const store = useTemplateRankingStore() + const today = new Date().toISOString().split('T')[0] + const freshness = store.computeFreshness(today) + expect(freshness).toBeCloseTo(1.0, 1) + }) + + it('returns ~0.5 for 90-day old template', () => { + const store = useTemplateRankingStore() + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0] + const freshness = store.computeFreshness(ninetyDaysAgo) + expect(freshness).toBeCloseTo(0.5, 1) + }) + + it('returns 0.1 minimum for very old template', () => { + const store = useTemplateRankingStore() + const freshness = store.computeFreshness('2020-01-01') + expect(freshness).toBe(0.1) + }) + + it('returns 0.5 for undefined date', () => { + const store = useTemplateRankingStore() + expect(store.computeFreshness(undefined)).toBe(0.5) + }) + + it('returns 0.5 for invalid date', () => { + const store = useTemplateRankingStore() + expect(store.computeFreshness('not-a-date')).toBe(0.5) + }) + }) + + describe('computeDefaultScore', () => { + it('uses default searchRank of 5 when not provided', () => { + const store = useTemplateRankingStore() + const score = store.computeDefaultScore( + 'template', + '2024-01-01', + undefined + ) + // With no usage score loaded, usage = 0 + // internal = 5/10 = 0.5, freshness ~0.1 (old date) + // score = 0 * 0.5 + 0.5 * 0.3 + 0.1 * 0.2 = 0.15 + 0.02 = 0.17 + expect(score).toBeCloseTo(0.17, 1) + }) + + it('high searchRank (10) boosts score', () => { + const store = useTemplateRankingStore() + const lowRank = store.computeDefaultScore('template', '2024-01-01', 1) + const highRank = store.computeDefaultScore('template', '2024-01-01', 10) + expect(highRank).toBeGreaterThan(lowRank) + }) + + it('low searchRank (1) demotes score', () => { + const store = useTemplateRankingStore() + const neutral = store.computeDefaultScore('template', '2024-01-01', 5) + const demoted = store.computeDefaultScore('template', '2024-01-01', 1) + expect(demoted).toBeLessThan(neutral) + }) + + it('searchRank difference is significant', () => { + const store = useTemplateRankingStore() + const rank1 = store.computeDefaultScore('template', '2024-01-01', 1) + const rank10 = store.computeDefaultScore('template', '2024-01-01', 10) + // Difference should be 0.9 * 0.3 = 0.27 (30% weight, 0.9 range) + expect(rank10 - rank1).toBeCloseTo(0.27, 2) + }) + }) + + describe('computePopularScore', () => { + it('does not use searchRank', () => { + const store = useTemplateRankingStore() + // Popular score ignores searchRank - just usage + freshness + const score1 = store.computePopularScore('template', '2024-01-01') + const score2 = store.computePopularScore('template', '2024-01-01') + expect(score1).toBe(score2) + }) + + it('newer templates score higher', () => { + const store = useTemplateRankingStore() + const today = new Date().toISOString().split('T')[0] + const oldScore = store.computePopularScore('template', '2020-01-01') + const newScore = store.computePopularScore('template', today) + expect(newScore).toBeGreaterThan(oldScore) + }) + }) + + describe('getUsageScore', () => { + it('returns 0 for unknown template', () => { + const store = useTemplateRankingStore() + expect(store.getUsageScore('unknown_template')).toBe(0) + }) + }) + + describe('searchRank edge cases', () => { + it('handles searchRank of 0 (should still work, treated as very low)', () => { + const store = useTemplateRankingStore() + const score = store.computeDefaultScore('template', '2024-01-01', 0) + expect(score).toBeGreaterThanOrEqual(0) + }) + + it('handles searchRank above 10 (clamping not enforced, but works)', () => { + const store = useTemplateRankingStore() + const rank10 = store.computeDefaultScore('template', '2024-01-01', 10) + const rank15 = store.computeDefaultScore('template', '2024-01-01', 15) + expect(rank15).toBeGreaterThan(rank10) + }) + + it('handles negative searchRank', () => { + const store = useTemplateRankingStore() + const score = store.computeDefaultScore('template', '2024-01-01', -5) + // Should still compute, just negative contribution from searchRank + expect(typeof score).toBe('number') + }) + }) +})