diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts index b87f3d6..942ee66 100644 --- a/src/lib/utils/fuzzy-search.ts +++ b/src/lib/utils/fuzzy-search.ts @@ -18,6 +18,8 @@ export default function enhancedSearch(options: { }): SearchResult[] { const { needle, haystack, property, mode = 'words', minScore = 0.3 } = options; + if (!needle) return haystack.map((item) => ({ item, score: 1, matchType: 'exact' })); + if (!Array.isArray(haystack)) { throw new Error('Haystack must be an array'); } @@ -48,7 +50,7 @@ export default function enhancedSearch(options: { // Sort by score (highest first), then by match type priority return results.sort((a, b) => { if (a.score !== b.score) return b.score - a.score; - + const typePriority = { exact: 3, word: 2, fuzzy: 1 }; return typePriority[b.matchType] - typePriority[a.matchType]; }); @@ -62,13 +64,17 @@ export function fuzzysearch(options: { haystack: T[]; property: keyof T | ((item: T) => string); }): T[] { - return enhancedSearch(options).map(result => result.item); + return enhancedSearch(options).map((result) => result.item); } /** * Score a match between needle and haystack */ -function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score: number; matchType: 'exact' | 'word' | 'fuzzy' } | null { +function scoreMatch( + needle: string, + haystack: string, + mode: SearchMode +): { score: number; matchType: 'exact' | 'word' | 'fuzzy' } | null { // Exact match gets highest score if (haystack === needle) { return { score: 1.0, matchType: 'exact' }; @@ -105,11 +111,11 @@ function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score function scoreWordMatch(needle: string, haystack: string): number { const words = haystack.split(/\s+/); const needleWords = needle.split(/\s+/); - + // Check for exact word matches let exactWordMatches = 0; let partialWordMatches = 0; - + for (const needleWord of needleWords) { let found = false; for (const word of words) { @@ -124,23 +130,25 @@ function scoreWordMatch(needle: string, haystack: string): number { } } } - + const totalNeedleWords = needleWords.length; if (exactWordMatches === totalNeedleWords) { return 0.9; // High score for all words matching exactly } - + if (exactWordMatches + partialWordMatches === totalNeedleWords) { - return 0.7 * (exactWordMatches / totalNeedleWords) + 0.3 * (partialWordMatches / totalNeedleWords); + return ( + 0.7 * (exactWordMatches / totalNeedleWords) + 0.3 * (partialWordMatches / totalNeedleWords) + ); } - + // Check if needle appears at the start of any word for (const word of words) { if (word.startsWith(needle)) { return 0.6; } } - + return 0; } @@ -151,41 +159,41 @@ function scoreFuzzyMatch(needle: string, haystack: string): number { if (!fuzzyMatchString(needle, haystack)) { return 0; } - + // Calculate a score based on how close the characters are let score = 0; let lastIndex = -1; let consecutiveMatches = 0; - + for (let i = 0; i < needle.length; i++) { const char = needle.charAt(i); const index = haystack.indexOf(char, lastIndex + 1); - + if (index === -1) { return 0; // This shouldn't happen if fuzzyMatchString returned true } - + if (index === lastIndex + 1) { consecutiveMatches++; score += 0.1; // Bonus for consecutive matches } else { consecutiveMatches = 0; } - + // Penalty based on distance const distance = index - lastIndex - 1; score += Math.max(0, 0.05 - distance * 0.01); - + lastIndex = index; } - + // Normalize score score = score / needle.length; - + // Length ratio bonus const lengthRatio = needle.length / haystack.length; - score *= (0.5 + lengthRatio * 0.5); - + score *= 0.5 + lengthRatio * 0.5; + return Math.min(0.5, Math.max(0.1, score)); // Cap fuzzy scores at 0.5 } diff --git a/src/routes/chat/search-modal.svelte b/src/routes/chat/search-modal.svelte index 57c925d..6016e81 100644 --- a/src/routes/chat/search-modal.svelte +++ b/src/routes/chat/search-modal.svelte @@ -6,6 +6,7 @@ import { session } from '$lib/state/session.svelte'; import { useQuery } from 'convex-svelte'; import { Debounced } from 'runed'; + import { tick } from 'svelte'; import SearchIcon from '~icons/lucide/search'; let open = $state(false); @@ -41,21 +42,30 @@

Search Conversations

- +
- { + if (!open) return; + setTimeout(() => { + console.log('focus', node, open); + if (open) node.focus(); + }, 50); + }} /> - -
- - @@ -66,39 +76,51 @@ {#if search.isLoading}
-
+
{:else if search.data?.length} -
+
{#each search.data as { conversation, messages, score, titleMatch }} -
-
-
-
+
+
+
+
{conversation.title}
-
+
{Math.round(score * 100)}%
-
+
{messages.length} matching message{messages.length !== 1 ? 's' : ''} {#if titleMatch} - • Title match + • Title match {/if}
- +
{/each}
{:else if debouncedInput.current.trim()} -
+

No results found for "{debouncedInput.current}"

-

Try a different search term or mode

+

Try a different search term or mode

{:else} -
+

Start typing to search your conversations

{/if}