diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts index 98e7208c9..cf907a1a9 100644 --- a/src/platform/assets/composables/useAssetBrowser.ts +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -1,3 +1,5 @@ +import { useFuse } from '@vueuse/integrations/useFuse' +import type { UseFuseOptions } from '@vueuse/integrations/useFuse' import { computed, ref } from 'vue' import type { Ref } from 'vue' @@ -15,19 +17,6 @@ function filterByCategory(category: string) { } } -function filterByQuery(query: string) { - return (asset: AssetItem) => { - if (!query) return true - const lowerQuery = query.toLowerCase() - const description = getAssetDescription(asset) - return ( - asset.name.toLowerCase().includes(lowerQuery) || - (description && description.toLowerCase().includes(lowerQuery)) || - asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)) - ) - } -} - function filterByFileFormats(formats: string[]) { return (asset: AssetItem) => { if (formats.length === 0) return true @@ -160,9 +149,31 @@ export function useAssetBrowser( return assets.value.filter(filterByCategory(selectedCategory.value)) }) + const fuseOptions: UseFuseOptions = { + fuseOptions: { + keys: [ + { name: 'name', weight: 0.4 }, + { name: 'tags', weight: 0.3 } + ], + threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all) + ignoreLocation: true, // Search anywhere in the string, not just at the beginning + includeScore: true + }, + matchAllWhenSearchEmpty: true + } + + const { results: fuseResults } = useFuse( + searchQuery, + categoryFilteredAssets, + fuseOptions + ) + + const searchFiltered = computed(() => + fuseResults.value.map((result) => result.item) + ) + const filteredAssets = computed(() => { - const filtered = categoryFilteredAssets.value - .filter(filterByQuery(searchQuery.value)) + const filtered = searchFiltered.value .filter(filterByFileFormats(filters.value.fileFormats)) .filter(filterByBaseModels(filters.value.baseModels)) diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts index 5e581c873..92ea78c33 100644 --- a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts +++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts @@ -136,8 +136,8 @@ describe('useAssetBrowser', () => { }) }) - describe('Search Functionality', () => { - it('searches across asset name', async () => { + describe('Fuzzy Search Functionality', () => { + it('searches across asset name with exact match', async () => { const assets = [ createApiAsset({ name: 'realistic_vision.safetensors' }), createApiAsset({ name: 'anime_style.ckpt' }), @@ -149,45 +149,148 @@ describe('useAssetBrowser', () => { searchQuery.value = 'realistic' await nextTick() - expect(filteredAssets.value).toHaveLength(2) + expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1) expect( - filteredAssets.value.every((asset) => + filteredAssets.value.some((asset) => asset.name.toLowerCase().includes('realistic') ) ).toBe(true) }) - it('searches in user metadata description', async () => { + it('searches across asset tags', async () => { const assets = [ createApiAsset({ name: 'model1.safetensors', - user_metadata: { description: 'fantasy artwork model' } + tags: ['models', 'checkpoints'] }), createApiAsset({ name: 'model2.safetensors', - user_metadata: { description: 'portrait photography' } + tags: ['models', 'loras'] }) ] const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets)) - searchQuery.value = 'fantasy' + searchQuery.value = 'checkpoints' await nextTick() - expect(filteredAssets.value).toHaveLength(1) - expect(filteredAssets.value[0].name).toBe('model1.safetensors') + expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1) + expect(filteredAssets.value[0].tags).toContain('checkpoints') }) - it('handles empty search results', async () => { + it('supports fuzzy matching with typos', async () => { + const assets = [ + createApiAsset({ name: 'checkpoint_model.safetensors' }), + createApiAsset({ name: 'lora_model.safetensors' }) + ] + + const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets)) + + // Intentional typo - fuzzy search should still find it + searchQuery.value = 'chckpoint' + await nextTick() + + expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1) + expect(filteredAssets.value[0].name).toContain('checkpoint') + }) + + it('handles empty search by returning all assets', async () => { + const assets = [ + createApiAsset({ name: 'test1.safetensors' }), + createApiAsset({ name: 'test2.safetensors' }) + ] + + const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets)) + + searchQuery.value = '' + await nextTick() + + expect(filteredAssets.value).toHaveLength(2) + }) + + it('handles no search results', async () => { const assets = [createApiAsset({ name: 'test.safetensors' })] const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets)) - searchQuery.value = 'nonexistent' + searchQuery.value = 'completelydifferentstring123' await nextTick() expect(filteredAssets.value).toHaveLength(0) }) + + it('performs case-insensitive search', async () => { + const assets = [ + createApiAsset({ name: 'RealisticVision.safetensors' }), + createApiAsset({ name: 'anime_style.ckpt' }) + ] + + const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets)) + + searchQuery.value = 'REALISTIC' + await nextTick() + + expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1) + expect(filteredAssets.value[0].name).toContain('Realistic') + }) + + it('combines fuzzy search with format filter', async () => { + const assets = [ + createApiAsset({ name: 'my_checkpoint_model.safetensors' }), + createApiAsset({ name: 'my_checkpoint_model.ckpt' }), + createApiAsset({ name: 'different_lora.safetensors' }) + ] + + const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser( + ref(assets) + ) + + searchQuery.value = 'checkpoint' + updateFilters({ + sortBy: 'name-asc', + fileFormats: ['safetensors'], + baseModels: [] + }) + await nextTick() + + expect(filteredAssets.value.length).toBeGreaterThanOrEqual(1) + expect( + filteredAssets.value.every((asset) => + asset.name.endsWith('.safetensors') + ) + ).toBe(true) + expect( + filteredAssets.value.some((asset) => asset.name.includes('checkpoint')) + ).toBe(true) + }) + + it('combines fuzzy search with base model filter', async () => { + const assets = [ + createApiAsset({ + name: 'realistic_sd15.safetensors', + user_metadata: { base_model: 'SD1.5' } + }), + createApiAsset({ + name: 'realistic_sdxl.safetensors', + user_metadata: { base_model: 'SDXL' } + }) + ] + + const { searchQuery, updateFilters, filteredAssets } = useAssetBrowser( + ref(assets) + ) + + searchQuery.value = 'realistic' + updateFilters({ + sortBy: 'name-asc', + fileFormats: [], + baseModels: ['SDXL'] + }) + await nextTick() + + expect(filteredAssets.value).toHaveLength(1) + expect(filteredAssets.value[0].name).toBe('realistic_sdxl.safetensors') + }) }) describe('Combined Search and Filtering', () => {