diff --git a/src/composables/useTemplateFiltering.test.ts b/src/composables/useTemplateFiltering.test.ts index 5f30e5ec1..dffcb2a0a 100644 --- a/src/composables/useTemplateFiltering.test.ts +++ b/src/composables/useTemplateFiltering.test.ts @@ -272,4 +272,108 @@ describe('useTemplateFiltering', () => { 'beta-pro' ]) }) + + it('incorporates search relevance into recommended sorting', async () => { + vi.useFakeTimers() + + const templates = ref([ + { + name: 'wan-video-exact', + title: 'Wan Video Template', + description: 'A template with Wan in title', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 10 + }, + { + name: 'qwen-image-partial', + title: 'Qwen Image Editor', + description: 'A template that contains w, a, n scattered', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 1000 // Higher usage but worse search match + }, + { + name: 'wan-text-exact', + title: 'Wan2.5: Text to Image', + description: 'Another exact match for Wan', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 50 + } + ]) + + const { searchQuery, sortBy, filteredTemplates } = + useTemplateFiltering(templates) + + // Search for "Wan" + searchQuery.value = 'Wan' + sortBy.value = 'recommended' + await nextTick() + await vi.runOnlyPendingTimersAsync() + await nextTick() + + // Templates with "Wan" in title should rank higher than Qwen despite lower usage + // because search relevance is now factored into the recommended sort + const results = filteredTemplates.value.map((t) => t.name) + + // Verify exact matches appear (Qwen might be filtered out by threshold) + expect(results).toContain('wan-video-exact') + expect(results).toContain('wan-text-exact') + + // If Qwen appears, it should be ranked lower than exact matches + if (results.includes('qwen-image-partial')) { + const wanIndex = results.indexOf('wan-video-exact') + const qwenIndex = results.indexOf('qwen-image-partial') + expect(wanIndex).toBeLessThan(qwenIndex) + } + + vi.useRealTimers() + }) + + it('preserves Fuse search order when using default sort', async () => { + vi.useFakeTimers() + + const templates = ref([ + { + name: 'portrait-basic', + title: 'Basic Portrait', + description: 'A basic template', + mediaType: 'image', + mediaSubtype: 'png' + }, + { + name: 'portrait-pro', + title: 'Portrait Pro Edition', + description: 'Advanced portrait features', + mediaType: 'image', + mediaSubtype: 'png' + }, + { + name: 'landscape-view', + title: 'Landscape Generator', + description: 'Generate landscapes', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const { searchQuery, sortBy, filteredTemplates } = + useTemplateFiltering(templates) + + searchQuery.value = 'Portrait Pro' + sortBy.value = 'default' + await nextTick() + await vi.runOnlyPendingTimersAsync() + await nextTick() + + const results = filteredTemplates.value.map((t) => t.name) + + // With default sort, Fuse's relevance ordering is preserved + // "Portrait Pro Edition" should be first as it's the best match + expect(results[0]).toBe('portrait-pro') + }) }) diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index fdb892e07..f6c8d7857 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -82,13 +82,31 @@ export function useTemplateFiltering( const debouncedSearchQuery = refDebounced(searchQuery, 50) - const filteredBySearch = computed(() => { + // Store Fuse search results with scores for use in sorting + const fuseSearchResults = computed(() => { if (!debouncedSearchQuery.value.trim()) { + return null + } + return fuse.value.search(debouncedSearchQuery.value) + }) + + // Map of template name to search score (lower is better in Fuse, 0 = perfect match) + const searchScoreMap = computed(() => { + const map = new Map() + if (fuseSearchResults.value) { + fuseSearchResults.value.forEach((result) => { + // Store the score (0 = perfect match, 1 = worst match) + map.set(result.item.name, result.score ?? 1) + }) + } + return map + }) + + const filteredBySearch = computed(() => { + if (!fuseSearchResults.value) { return templatesArray.value } - - const results = fuse.value.search(debouncedSearchQuery.value) - return results.map((result) => result.item) + return fuseSearchResults.value.map((result) => result.item) }) const filteredByModels = computed(() => { @@ -165,31 +183,66 @@ export function useTemplateFiltering( { immediate: true } ) + // Helper to get search relevance score (higher is better, 0-1 range) + // Fuse returns scores where 0 = perfect match, 1 = worst match + // We invert it so higher = better for combining with other scores + const getSearchRelevance = (template: TemplateInfo): number => { + const fuseScore = searchScoreMap.value.get(template.name) + if (fuseScore === undefined) return 0 // Not in search results or no search + return 1 - fuseScore // Invert: 0 (worst) -> 1 (best) + } + + const hasActiveSearch = computed( + () => debouncedSearchQuery.value.trim() !== '' + ) + const sortedTemplates = computed(() => { const templates = [...filteredByRunsOn.value] switch (sortBy.value) { case 'recommended': - // Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2 + // When searching, heavily weight search relevance + // Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4 + // Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2 return templates.sort((a, b) => { - const scoreA = rankingStore.computeDefaultScore( + const baseScoreA = rankingStore.computeDefaultScore( a.date, a.searchRank, a.usage ) - const scoreB = rankingStore.computeDefaultScore( + const baseScoreB = rankingStore.computeDefaultScore( b.date, b.searchRank, b.usage ) - return scoreB - scoreA + + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + const finalA = searchA * 0.6 + baseScoreA * 0.4 + const finalB = searchB * 0.6 + baseScoreB * 0.4 + return finalB - finalA + } + + return baseScoreB - baseScoreA }) case 'popular': - // User-driven: usage × 0.9 + freshness × 0.1 + // When searching, include search relevance + // Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5 + // Formula without search: 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 + const baseScoreA = rankingStore.computePopularScore(a.date, a.usage) + const baseScoreB = rankingStore.computePopularScore(b.date, b.usage) + + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + const finalA = searchA * 0.5 + baseScoreA * 0.5 + const finalB = searchB * 0.5 + baseScoreB * 0.5 + return finalB - finalA + } + + return baseScoreB - baseScoreA }) case 'alphabetical': return templates.sort((a, b) => { @@ -209,6 +262,12 @@ export function useTemplateFiltering( const vramB = getVramMetric(b) if (vramA === vramB) { + // Use search relevance as tiebreaker when searching + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + if (searchA !== searchB) return searchB - searchA + } const nameA = a.title || a.name || '' const nameB = b.title || b.name || '' return nameA.localeCompare(nameB) @@ -225,11 +284,20 @@ export function useTemplateFiltering( typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY const sizeB = typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY - if (sizeA === sizeB) return 0 + if (sizeA === sizeB) { + // Use search relevance as tiebreaker when searching + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + if (searchA !== searchB) return searchB - searchA + } + return 0 + } return sizeA - sizeB }) case 'default': default: + // 'default' preserves Fuse's search order (already sorted by relevance) return templates } })