From 160d158152e0f7946142027fad580ba325039fbf Mon Sep 17 00:00:00 2001 From: Louis Date: Mon, 28 Jul 2025 23:33:11 +0700 Subject: [PATCH] fix: search models result in hub should be sorted by weight (#5954) --- web-app/package.json | 1 + web-app/src/lib/__tests__/utils.test.ts | 35 --------------------- web-app/src/lib/utils.ts | 21 ------------- web-app/src/routes/hub/index.tsx | 42 ++++++++++++++----------- 4 files changed, 25 insertions(+), 74 deletions(-) diff --git a/web-app/package.json b/web-app/package.json index 0e124be08..f469a4998 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -43,6 +43,7 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.1", "emoji-picker-react": "^4.12.2", + "fuse.js": "^7.1.0", "fzf": "^0.5.2", "i18next": "^25.0.1", "katex": "^0.16.22", diff --git a/web-app/src/lib/__tests__/utils.test.ts b/web-app/src/lib/__tests__/utils.test.ts index a671643df..25bc91334 100644 --- a/web-app/src/lib/__tests__/utils.test.ts +++ b/web-app/src/lib/__tests__/utils.test.ts @@ -3,7 +3,6 @@ import { getProviderLogo, getProviderTitle, getReadableLanguageName, - fuzzySearch, toGigabytes, formatMegaBytes, formatDuration, @@ -67,40 +66,6 @@ describe('getReadableLanguageName', () => { }) }) -describe('fuzzySearch', () => { - it('returns true for exact matches', () => { - expect(fuzzySearch('hello', 'hello')).toBe(true) - expect(fuzzySearch('test', 'test')).toBe(true) - }) - - it('returns true for subsequence matches', () => { - expect(fuzzySearch('hlo', 'hello')).toBe(true) - expect(fuzzySearch('js', 'javascript')).toBe(true) - expect(fuzzySearch('abc', 'aabbcc')).toBe(true) - }) - - it('returns false when needle is longer than haystack', () => { - expect(fuzzySearch('hello', 'hi')).toBe(false) - expect(fuzzySearch('test', 'te')).toBe(false) - }) - - it('returns false for non-matching patterns', () => { - expect(fuzzySearch('xyz', 'hello')).toBe(false) - expect(fuzzySearch('ba', 'abc')).toBe(false) - }) - - it('handles empty strings', () => { - expect(fuzzySearch('', '')).toBe(true) - expect(fuzzySearch('', 'hello')).toBe(true) - expect(fuzzySearch('h', '')).toBe(false) - }) - - it('is case sensitive', () => { - expect(fuzzySearch('H', 'hello')).toBe(false) - expect(fuzzySearch('h', 'Hello')).toBe(false) - }) -}) - describe('toGigabytes', () => { it('returns empty string for falsy inputs', () => { expect(toGigabytes(0)).toBe('') diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index e95c8343e..0234d220e 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -99,27 +99,6 @@ export const isLocalProvider = (provider: string) => { return extension && 'load' in extension } -export function fuzzySearch(needle: string, haystack: string) { - const hlen = haystack.length - const nlen = needle.length - if (nlen > hlen) { - return false - } - if (nlen === hlen) { - return needle === haystack - } - outer: for (let i = 0, j = 0; i < nlen; i++) { - const nch = needle.charCodeAt(i) - while (j < hlen) { - if (haystack.charCodeAt(j++) === nch) { - continue outer - } - } - return false - } - return true -} - export const toGigabytes = ( input: number, options?: { hideUnit?: boolean; toFixed?: number } diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index 5aee060ed..7c904d1ee 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -3,7 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useModelSources } from '@/hooks/useModelSources' -import { cn, fuzzySearch } from '@/lib/utils' +import { cn } from '@/lib/utils' import { useState, useMemo, @@ -38,6 +38,7 @@ import { Progress } from '@/components/ui/progress' import HeaderPage from '@/containers/HeaderPage' import { Loader } from 'lucide-react' import { useTranslation } from '@/i18n/react-i18next-compat' +import Fuse from 'fuse.js' type ModelProps = { model: CatalogModel @@ -62,6 +63,12 @@ function Hub() { { value: 'newest', name: t('hub:sortNewest') }, { value: 'most-downloaded', name: t('hub:sortMostDownloaded') }, ] + const searchOptions = { + includeScore: true, + // Search in `author` and in `tags` array + keys: ['model_name', 'quants.model_id'], + } + const { sources, addSource, fetchSources, loading } = useModelSources() const search = useSearch({ from: route.hub.index as any }) const [searchValue, setSearchValue] = useState('') @@ -177,24 +184,22 @@ function Hub() { }) }, [sortSelected, sources]) - // Filtered models + // Filtered models (debounced search) + const [debouncedSearchValue, setDebouncedSearchValue] = useState(searchValue) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchValue(searchValue) + }, 300) + return () => clearTimeout(handler) + }, [searchValue]) + const filteredModels = useMemo(() => { let filtered = sortedModels // Apply search filter - if (searchValue.length) { - filtered = filtered?.filter( - (e) => - fuzzySearch( - searchValue.replace(/\s+/g, '').toLowerCase(), - e.model_name.toLowerCase() - ) || - e.quants.some((model) => - fuzzySearch( - searchValue.replace(/\s+/g, '').toLowerCase(), - model.model_id.toLowerCase() - ) - ) - ) + if (debouncedSearchValue.length) { + const fuse = new Fuse(filtered, searchOptions) + filtered = fuse.search(debouncedSearchValue).map((result) => result.item) } // Apply downloaded filter if (showOnlyDownloaded) { @@ -212,11 +217,12 @@ function Hub() { } return filtered }, [ - searchValue, sortedModels, + debouncedSearchValue, showOnlyDownloaded, - llamaProvider?.models, huggingFaceRepo, + searchOptions, + llamaProvider?.models, ]) // The virtualizer