better search

This commit is contained in:
Thomas G. Lopes 2025-06-18 18:31:21 +01:00
parent 10332fbaa9
commit 9d36105fbd
2 changed files with 73 additions and 43 deletions

View file

@ -18,6 +18,8 @@ export default function enhancedSearch<T>(options: {
}): SearchResult<T>[] { }): SearchResult<T>[] {
const { needle, haystack, property, mode = 'words', minScore = 0.3 } = options; 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)) { if (!Array.isArray(haystack)) {
throw new Error('Haystack must be an array'); throw new Error('Haystack must be an array');
} }
@ -62,13 +64,17 @@ export function fuzzysearch<T>(options: {
haystack: T[]; haystack: T[];
property: keyof T | ((item: T) => string); property: keyof T | ((item: T) => string);
}): T[] { }): T[] {
return enhancedSearch(options).map(result => result.item); return enhancedSearch(options).map((result) => result.item);
} }
/** /**
* Score a match between needle and haystack * 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 // Exact match gets highest score
if (haystack === needle) { if (haystack === needle) {
return { score: 1.0, matchType: 'exact' }; return { score: 1.0, matchType: 'exact' };
@ -131,7 +137,9 @@ function scoreWordMatch(needle: string, haystack: string): number {
} }
if (exactWordMatches + partialWordMatches === totalNeedleWords) { 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 // Check if needle appears at the start of any word
@ -184,7 +192,7 @@ function scoreFuzzyMatch(needle: string, haystack: string): number {
// Length ratio bonus // Length ratio bonus
const lengthRatio = needle.length / haystack.length; 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 return Math.min(0.5, Math.max(0.1, score)); // Cap fuzzy scores at 0.5
} }

View file

@ -6,6 +6,7 @@
import { session } from '$lib/state/session.svelte'; import { session } from '$lib/state/session.svelte';
import { useQuery } from 'convex-svelte'; import { useQuery } from 'convex-svelte';
import { Debounced } from 'runed'; import { Debounced } from 'runed';
import { tick } from 'svelte';
import SearchIcon from '~icons/lucide/search'; import SearchIcon from '~icons/lucide/search';
let open = $state(false); let open = $state(false);
@ -46,16 +47,25 @@
<input <input
bind:this={inputEl} bind:this={inputEl}
bind:value={input} bind:value={input}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Search conversations and messages..." placeholder="Search conversations and messages..."
{@attach (node) => {
if (!open) return;
setTimeout(() => {
console.log('focus', node, open);
if (open) node.focus();
}, 50);
}}
/> />
<div class="flex gap-2 items-center"> <div class="flex items-center gap-2">
<label for="search-mode" class="text-sm font-medium text-muted-foreground">Search mode:</label> <label for="search-mode" class="text-muted-foreground text-sm font-medium"
>Search mode:</label
>
<select <select
id="search-mode" id="search-mode"
bind:value={searchMode} bind:value={searchMode}
class="rounded border border-input bg-background px-2 py-1 text-xs" class="border-input bg-background rounded border px-2 py-1 text-xs"
> >
<option value="words">Word matching</option> <option value="words">Word matching</option>
<option value="exact">Exact match</option> <option value="exact">Exact match</option>
@ -66,39 +76,51 @@
{#if search.isLoading} {#if search.isLoading}
<div class="flex justify-center py-8"> <div class="flex justify-center py-8">
<div class="size-6 animate-spin rounded-full border-2 border-current border-t-transparent"></div> <div
class="size-6 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
</div> </div>
{:else if search.data?.length} {:else if search.data?.length}
<div class="space-y-2 max-h-96 overflow-y-auto"> <div class="max-h-96 space-y-2 overflow-y-auto">
{#each search.data as { conversation, messages, score, titleMatch }} {#each search.data as { conversation, messages, score, titleMatch }}
<div class="border-border flex items-center justify-between gap-2 rounded-lg border px-3 py-2"> <div
<div class="flex-1 min-w-0"> class="border-border flex items-center justify-between gap-2 rounded-lg border px-3 py-2"
<div class="flex items-center gap-2 mb-1"> >
<div class="font-medium truncate" class:text-blue-600={titleMatch}> <div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<div class={['truncate font-medium', titleMatch && 'text-heading']}>
{conversation.title} {conversation.title}
</div> </div>
<div class="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> <div class="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-xs">
{Math.round(score * 100)}% {Math.round(score * 100)}%
</div> </div>
</div> </div>
<div class="text-xs text-muted-foreground"> <div class="text-muted-foreground text-xs">
{messages.length} matching message{messages.length !== 1 ? 's' : ''} {messages.length} matching message{messages.length !== 1 ? 's' : ''}
{#if titleMatch} {#if titleMatch}
<span class="text-blue-600">• Title match</span> <span class="text-heading">• Title match</span>
{/if} {/if}
</div> </div>
</div> </div>
<Button variant="secondary" size="sm" class="text-xs shrink-0">View</Button> <Button
variant="secondary"
size="sm"
class="shrink-0 text-xs"
href="/chat/{conversation._id}"
onclick={() => (open = false)}
>
View
</Button>
</div> </div>
{/each} {/each}
</div> </div>
{:else if debouncedInput.current.trim()} {:else if debouncedInput.current.trim()}
<div class="text-center py-8 text-muted-foreground"> <div class="text-muted-foreground py-8 text-center">
<p>No results found for "{debouncedInput.current}"</p> <p>No results found for "{debouncedInput.current}"</p>
<p class="text-xs mt-1">Try a different search term or mode</p> <p class="mt-1 text-xs">Try a different search term or mode</p>
</div> </div>
{:else} {:else}
<div class="text-center py-8 text-muted-foreground"> <div class="text-muted-foreground py-8 text-center">
<p>Start typing to search your conversations</p> <p>Start typing to search your conversations</p>
</div> </div>
{/if} {/if}