feat(webapp): Replace Fuse.js with Fzf for thread search and enhance highlighting (#5052)

This commit replaces Fuse.js with Fzf for client-side thread searching
within the web application, offering potentially improved performance and
a different fuzzy matching algorithm.

Key changes include:

- Updated `package.json` to remove `fuse.js` and add `fzf`.
- Refactored `useThreads.ts` hook:
    - Replaced Fuse.js instantiation and search logic with Fzf.
    - Integrated a new `highlightFzfMatch` utility to return thread
      titles with HTML highlighting for matched characters.
- Created `utils/highlight.ts` for the `highlightFzfMatch` function.
- Updated `ThreadList.tsx`:
    - Renders highlighted thread titles using `dangerouslySetInnerHTML`.
    - Ensures the rename functionality uses and edits a plain text
      version of the title, stripping any highlight tags.
- Updated `index.css`:
    - Modified the `.search-highlight` class to use `font-bold` and
      `color-mix(in srgb, currentColor 80%, white 20%)` for a
      subtly brighter text effect on highlighted matches, replacing
      previous styling.

This provides a more robust search experience with clear visual feedback
for matched terms in the thread list.
This commit is contained in:
Sam Hoang Van 2025-05-21 22:31:01 +07:00 committed by GitHub
parent 0627f29059
commit ad962c2cf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 105 additions and 63 deletions

View File

@ -37,7 +37,7 @@
"@uiw/react-textarea-code-editor": "^3.1.1", "@uiw/react-textarea-code-editor": "^3.1.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"culori": "^4.0.1", "culori": "^4.0.1",
"fuse.js": "^7.1.0", "fzf": "^0.5.2",
"i18next": "^25.0.1", "i18next": "^25.0.1",
"katex": "^0.16.22", "katex": "^0.16.22",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",

View File

@ -118,7 +118,7 @@ const LeftPanel = () => {
<input <input
type="text" type="text"
placeholder={t('common.search')} placeholder={t('common.search')}
className="w-full px-2 pl-7 py-1 bg-left-panel-fg/10 rounded text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10" className="w-full pl-7 pr-8 py-1 bg-left-panel-fg/10 rounded text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />

View File

@ -80,7 +80,13 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
} }
} }
const [title, setTitle] = useState(thread.title || 'New Thread') const plainTitleForRename = useMemo(() => {
// Basic HTML stripping for simple span tags.
// If thread.title is undefined or null, treat as empty string before replace.
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '');
}, [thread.title]);
const [title, setTitle] = useState(plainTitleForRename || 'New Thread');
return ( return (
<div <div
@ -96,9 +102,10 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
)} )}
> >
<div className="py-1 pr-2 truncate"> <div className="py-1 pr-2 truncate">
<span className="text-left-panel-fg/90"> <span
{thread.title || 'New Thread'} className="text-left-panel-fg/90"
</span> dangerouslySetInnerHTML={{ __html: thread.title || 'New Thread' }}
/>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu <DropdownMenu
@ -141,7 +148,7 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setOpenDropdown(false) setOpenDropdown(false)
setTitle(thread.title) setTitle(plainTitleForRename || 'New Thread');
} }
}} }}
> >

View File

@ -3,7 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware'
import { localStorageKey } from '@/constants/localStorage' import { localStorageKey } from '@/constants/localStorage'
import { ulid } from 'ulidx' import { ulid } from 'ulidx'
import { createThread, deleteThread, updateThread } from '@/services/threads' import { createThread, deleteThread, updateThread } from '@/services/threads'
import Fuse from 'fuse.js' import { Fzf } from 'fzf'
import { highlightFzfMatch } from '../utils/highlight'
type ThreadState = { type ThreadState = {
threads: Record<string, Thread> threads: Record<string, Thread>
currentThreadId?: string currentThreadId?: string
@ -25,19 +26,7 @@ type ThreadState = {
updateCurrentThreadModel: (model: ThreadModel) => void updateCurrentThreadModel: (model: ThreadModel) => void
getFilteredThreads: (searchTerm: string) => Thread[] getFilteredThreads: (searchTerm: string) => Thread[]
updateCurrentThreadAssistant: (assistant: Assistant) => void updateCurrentThreadAssistant: (assistant: Assistant) => void
searchIndex: Fuse<Thread> | null searchIndex: Fzf<Thread[]> | null
}
const fuseOptions = {
keys: ['title'],
threshold: 0.4, // Increased threshold to require more exact matches
includeMatches: true, // Keeping this to show where matches occur
ignoreLocation: true, // Consider the location of matches
useExtendedSearch: true, // Disable extended search for more precise matching
distance: 40, // Reduced edit distance for stricter fuzzy matching
tokenize: false, // Keep tokenization for word-level matching
matchAllTokens: true, // Require all tokens to match for better precision
findAllMatches: false, // Only find the first match to reduce noise
} }
export const useThreads = create<ThreadState>()( export const useThreads = create<ThreadState>()(
@ -54,13 +43,14 @@ export const useThreads = create<ThreadState>()(
}) })
}) })
const threadMap = threads.reduce( const threadMap = threads.reduce(
(acc, thread) => { (acc: Record<string, Thread>, thread) => {
acc[thread.id] = thread acc[thread.id] = thread
return acc return acc
}, }, {} as Record<string, Thread>)
{} as Record<string, Thread> set({
) threads: threadMap,
set({ threads: threadMap }) searchIndex: new Fzf<Thread[]>(Object.values(threadMap), { selector: (item: Thread) => item.title })
})
}, },
getFilteredThreads: (searchTerm: string) => { getFilteredThreads: (searchTerm: string) => {
const { threads, searchIndex } = get() const { threads, searchIndex } = get()
@ -71,23 +61,27 @@ export const useThreads = create<ThreadState>()(
return Object.values(threads) return Object.values(threads)
} }
const currentIndex = let currentIndex = searchIndex
searchIndex && searchIndex.search != undefined if (!currentIndex) {
? searchIndex currentIndex = new Fzf<Thread[]>(
: new Fuse( Object.values(threads),
Object.values(threads).map((item) => item), { selector: (item: Thread) => item.title }
fuseOptions
) )
set({ searchIndex: currentIndex }) set({ searchIndex: currentIndex })
}
// Use the index to search and return matching threads // Use the index to search and return matching threads
const fzfResults = currentIndex.find(searchTerm)
const searchResults = currentIndex.search(searchTerm) return fzfResults.map((result: { item: Thread; positions: Set<number> }) => {
const validIds = searchResults.map((result) => result.item.id) const thread = result.item; // Fzf stores the original item here
return Object.values(get().threads).filter((thread) => // Ensure result.positions is an array, default to empty if undefined
validIds.includes(thread.id) const positions = Array.from(result.positions) || [];
) const highlightedTitle = highlightFzfMatch(thread.title, positions);
return {
...thread,
title: highlightedTitle, // Override title with highlighted version
};
});
}, },
toggleFavorite: (threadId) => { toggleFavorite: (threadId) => {
set((state) => { set((state) => {
@ -113,7 +107,7 @@ export const useThreads = create<ThreadState>()(
deleteThread(threadId) deleteThread(threadId)
return { return {
threads: remainingThreads, threads: remainingThreads,
searchIndex: new Fuse(Object.values(remainingThreads), fuseOptions), searchIndex: new Fzf<Thread[]>(Object.values(remainingThreads), { selector: (item: Thread) => item.title })
} }
}) })
}, },
@ -125,6 +119,7 @@ export const useThreads = create<ThreadState>()(
}) })
return { return {
threads: {}, threads: {},
searchIndex: null // Or new Fzf([], {selector...})
} }
}) })
}, },
@ -166,17 +161,18 @@ export const useThreads = create<ThreadState>()(
updated: Date.now() / 1000, updated: Date.now() / 1000,
assistants: assistant ? [assistant] : [], assistants: assistant ? [assistant] : [],
} }
set((state) => ({
searchIndex: new Fuse(Object.values(state.threads), fuseOptions),
}))
return await createThread(newThread).then((createdThread) => { return await createThread(newThread).then((createdThread) => {
set((state) => ({ set((state) => {
threads: { const newThreads = {
...state.threads, ...state.threads,
[createdThread.id]: createdThread, [createdThread.id]: createdThread,
}, };
return {
threads: newThreads,
currentThreadId: createdThread.id, currentThreadId: createdThread.id,
})) searchIndex: new Fzf<Thread[]>(Object.values(newThreads), { selector: (item: Thread) => item.title }),
};
})
return createdThread return createdThread
}) })
}, },
@ -224,20 +220,11 @@ export const useThreads = create<ThreadState>()(
...thread, ...thread,
title: newTitle, title: newTitle,
} }
updateThread(updatedThread) updateThread(updatedThread) // External call, order is fine
const newThreads = { ...state.threads, [threadId]: updatedThread };
return { return {
threads: { threads: newThreads,
...state.threads, searchIndex: new Fzf<Thread[]>(Object.values(newThreads), { selector: (item: Thread) => item.title }),
[threadId]: updatedThread,
},
// Update search index with the new title
searchIndex: new Fuse(
Object.values({
...state.threads,
[threadId]: updatedThread,
}),
fuseOptions
),
} }
}) })
}, },

View File

@ -88,3 +88,10 @@
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
} }
@layer components {
.search-highlight {
@apply font-bold;
color: color-mix(in srgb, currentColor 80%, white 20%);
}
}

View File

@ -0,0 +1,41 @@
// web-app/src/utils/highlight.ts
export function highlightFzfMatch(text: string, positions: number[], highlightClassName: string = "search-highlight") {
if (!text || !positions || !positions.length) return text;
const parts: { text: string; highlight: boolean }[] = [];
let lastIndex = 0;
// Sort positions to ensure we process them in order
const sortedPositions = [...positions].sort((a, b) => a - b);
sortedPositions.forEach((pos) => {
if (pos > lastIndex) {
parts.push({
text: text.substring(lastIndex, pos),
highlight: false
});
}
if (pos < text.length) { // Ensure pos is within bounds
parts.push({
text: text[pos],
highlight: true
});
}
lastIndex = pos + 1;
});
if (lastIndex < text.length) {
parts.push({
text: text.substring(lastIndex),
highlight: false
});
}
return parts
.map(part =>
part.highlight
? `<span class="${highlightClassName}">${part.text}</span>`
: part.text
)
.join('');
}