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:
parent
0627f29059
commit
ad962c2cf6
@ -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",
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
};
|
||||||
currentThreadId: createdThread.id,
|
return {
|
||||||
}))
|
threads: newThreads,
|
||||||
|
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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
web-app/src/utils/highlight.ts
Normal file
41
web-app/src/utils/highlight.ts
Normal 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('');
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user