better search
This commit is contained in:
parent
10332fbaa9
commit
9d36105fbd
2 changed files with 73 additions and 43 deletions
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +50,7 @@ export default function enhancedSearch<T>(options: {
|
||||||
// Sort by score (highest first), then by match type priority
|
// Sort by score (highest first), then by match type priority
|
||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
if (a.score !== b.score) return b.score - a.score;
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
|
||||||
const typePriority = { exact: 3, word: 2, fuzzy: 1 };
|
const typePriority = { exact: 3, word: 2, fuzzy: 1 };
|
||||||
return typePriority[b.matchType] - typePriority[a.matchType];
|
return typePriority[b.matchType] - typePriority[a.matchType];
|
||||||
});
|
});
|
||||||
|
|
@ -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' };
|
||||||
|
|
@ -105,11 +111,11 @@ function scoreMatch(needle: string, haystack: string, mode: SearchMode): { score
|
||||||
function scoreWordMatch(needle: string, haystack: string): number {
|
function scoreWordMatch(needle: string, haystack: string): number {
|
||||||
const words = haystack.split(/\s+/);
|
const words = haystack.split(/\s+/);
|
||||||
const needleWords = needle.split(/\s+/);
|
const needleWords = needle.split(/\s+/);
|
||||||
|
|
||||||
// Check for exact word matches
|
// Check for exact word matches
|
||||||
let exactWordMatches = 0;
|
let exactWordMatches = 0;
|
||||||
let partialWordMatches = 0;
|
let partialWordMatches = 0;
|
||||||
|
|
||||||
for (const needleWord of needleWords) {
|
for (const needleWord of needleWords) {
|
||||||
let found = false;
|
let found = false;
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
|
|
@ -124,23 +130,25 @@ function scoreWordMatch(needle: string, haystack: string): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNeedleWords = needleWords.length;
|
const totalNeedleWords = needleWords.length;
|
||||||
if (exactWordMatches === totalNeedleWords) {
|
if (exactWordMatches === totalNeedleWords) {
|
||||||
return 0.9; // High score for all words matching exactly
|
return 0.9; // High score for all words matching exactly
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (word.startsWith(needle)) {
|
if (word.startsWith(needle)) {
|
||||||
return 0.6;
|
return 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,41 +159,41 @@ function scoreFuzzyMatch(needle: string, haystack: string): number {
|
||||||
if (!fuzzyMatchString(needle, haystack)) {
|
if (!fuzzyMatchString(needle, haystack)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate a score based on how close the characters are
|
// Calculate a score based on how close the characters are
|
||||||
let score = 0;
|
let score = 0;
|
||||||
let lastIndex = -1;
|
let lastIndex = -1;
|
||||||
let consecutiveMatches = 0;
|
let consecutiveMatches = 0;
|
||||||
|
|
||||||
for (let i = 0; i < needle.length; i++) {
|
for (let i = 0; i < needle.length; i++) {
|
||||||
const char = needle.charAt(i);
|
const char = needle.charAt(i);
|
||||||
const index = haystack.indexOf(char, lastIndex + 1);
|
const index = haystack.indexOf(char, lastIndex + 1);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return 0; // This shouldn't happen if fuzzyMatchString returned true
|
return 0; // This shouldn't happen if fuzzyMatchString returned true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === lastIndex + 1) {
|
if (index === lastIndex + 1) {
|
||||||
consecutiveMatches++;
|
consecutiveMatches++;
|
||||||
score += 0.1; // Bonus for consecutive matches
|
score += 0.1; // Bonus for consecutive matches
|
||||||
} else {
|
} else {
|
||||||
consecutiveMatches = 0;
|
consecutiveMatches = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Penalty based on distance
|
// Penalty based on distance
|
||||||
const distance = index - lastIndex - 1;
|
const distance = index - lastIndex - 1;
|
||||||
score += Math.max(0, 0.05 - distance * 0.01);
|
score += Math.max(0, 0.05 - distance * 0.01);
|
||||||
|
|
||||||
lastIndex = index;
|
lastIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize score
|
// Normalize score
|
||||||
score = score / needle.length;
|
score = score / needle.length;
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -41,21 +42,30 @@
|
||||||
<Modal bind:open>
|
<Modal bind:open>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold">Search Conversations</h2>
|
<h2 class="text-lg font-semibold">Search Conversations</h2>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<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"
|
||||||
<select
|
>Search mode:</label
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue