Merge branch 'full-text-search'

This commit is contained in:
Thomas G. Lopes 2025-06-18 18:42:12 +01:00
commit 3b0de78236
8 changed files with 503 additions and 50 deletions

View file

@ -1,11 +1,12 @@
import { v } from 'convex/values';
import { api } from './_generated/api';
import { query } from './_generated/server';
import { type Id } from './_generated/dataModel';
import { type SessionObj } from './betterAuth';
import { messageRoleValidator } from './schema';
import { mutation } from './functions';
import enhancedSearch, { type SearchResult } from '../../utils/fuzzy-search';
import { getFirstSentence } from '../../utils/strings';
import { api } from './_generated/api';
import { type Doc, type Id } from './_generated/dataModel';
import { query } from './_generated/server';
import { type SessionObj } from './betterAuth';
import { mutation } from './functions';
import { messageRoleValidator } from './schema';
export const get = query({
args: {
@ -301,3 +302,81 @@ export const remove = mutation({
await ctx.db.delete(args.conversation_id);
},
});
export const search = query({
args: {
session_token: v.string(),
search_term: v.string(),
search_mode: v.optional(v.union(v.literal('exact'), v.literal('words'), v.literal('fuzzy'))),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
type ConversationSearchResult = {
conversation: Doc<'conversations'>;
messages: Doc<'messages'>[];
score: number;
titleMatch: boolean;
};
if (!args.search_term.trim()) return [];
const searchMode = args.search_mode || 'words';
const results: ConversationSearchResult[] = [];
// Get all conversations for the user
const conversations = await ctx.db
.query('conversations')
.withIndex('by_user', (q) => q.eq('user_id', session.userId))
.collect();
// Search through conversations and messages
for (const conversation of conversations) {
// Get messages for this conversation
const conversationMessages = await ctx.db
.query('messages')
.withIndex('by_conversation', (q) => q.eq('conversation_id', conversation._id))
.collect();
// Search title
const titleResults = enhancedSearch({
needle: args.search_term,
haystack: [conversation],
property: 'title',
mode: searchMode,
minScore: 0.3,
});
// Search messages
const messageResults = enhancedSearch({
needle: args.search_term,
haystack: conversationMessages,
property: 'content',
mode: searchMode,
minScore: 0.3,
});
// If we have matches in title or messages, add to results
if (titleResults.length > 0 || messageResults.length > 0) {
const titleScore = titleResults.length > 0 ? titleResults[0]?.score ?? 0 : 0;
const messageScore = messageResults.length > 0 ? Math.max(...messageResults.map(r => r.score)) : 0;
results.push({
conversation,
messages: messageResults.map(r => r.item),
score: Math.max(titleScore, messageScore),
titleMatch: titleResults.length > 0,
});
}
}
// Sort by score (highest first)
return results.sort((a, b) => b.score - a.score);
},
});

View file

@ -1,6 +1,6 @@
import { internal } from './_generated/api';
import { query } from './_generated/server';
import { SessionObj } from './betterAuth';
import { type SessionObj } from './betterAuth';
import { mutation } from './functions';
import { v } from 'convex/values';

View file

@ -1,15 +1,24 @@
export type SearchMode = 'exact' | 'words' | 'fuzzy';
export interface SearchResult<T> {
item: T;
score: number;
matchType: 'exact' | 'word' | 'fuzzy';
}
/**
* Generic fuzzy search function that searches through arrays and returns matching items
*
* @param options Configuration object for the fuzzy search
* @returns Array of items that match the search criteria
* Enhanced search function with scoring and multiple search modes
*/
export default function fuzzysearch<T>(options: {
export default function enhancedSearch<T>(options: {
needle: string;
haystack: T[];
property: keyof T | ((item: T) => string);
}): T[] {
const { needle, haystack, property } = options;
mode?: SearchMode;
minScore?: number;
}): SearchResult<T>[] {
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');
@ -19,26 +28,179 @@ export default function fuzzysearch<T>(options: {
throw new Error('Property selector is required');
}
// Convert needle to lowercase for case-insensitive matching
const lowerNeedle = needle.toLowerCase();
const lowerNeedle = needle.toLowerCase().trim();
if (!lowerNeedle) return [];
// Filter the haystack to find matching items
return haystack.filter((item) => {
// Extract the string value from the item based on the property selector
const results: SearchResult<T>[] = [];
for (const item of haystack) {
const value = typeof property === 'function' ? property(item) : String(item[property]);
// Convert to lowercase for case-insensitive matching
const lowerValue = value.toLowerCase();
// Perform the fuzzy search
return fuzzyMatchString(lowerNeedle, lowerValue);
const result = scoreMatch(lowerNeedle, lowerValue, mode);
if (result && result.score >= minScore) {
results.push({
item,
score: result.score,
matchType: result.matchType,
});
}
}
// 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];
});
}
/**
* Internal helper function that performs the actual fuzzy string matching
* Legacy fuzzy search function for backward compatibility
*/
function fuzzyMatchString(needle: string, haystack: string): boolean {
export function fuzzysearch<T>(options: {
needle: string;
haystack: T[];
property: keyof T | ((item: T) => string);
}): T[] {
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 {
// Exact match gets highest score
if (haystack === needle) {
return { score: 1.0, matchType: 'exact' };
}
// Check for exact substring match
if (haystack.includes(needle)) {
const score = needle.length / haystack.length;
return { score: Math.max(0.8, score), matchType: 'exact' };
}
// Word boundary matching - check if needle matches at word boundaries
if (mode === 'words' || mode === 'fuzzy') {
const wordScore = scoreWordMatch(needle, haystack);
if (wordScore > 0) {
return { score: wordScore, matchType: 'word' };
}
}
// Fuzzy matching as fallback
if (mode === 'fuzzy') {
const fuzzyScore = scoreFuzzyMatch(needle, haystack);
if (fuzzyScore > 0) {
return { score: fuzzyScore, matchType: 'fuzzy' };
}
}
return null;
}
/**
* Score word boundary matches
*/
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) {
if (word === needleWord) {
exactWordMatches++;
found = true;
break;
} else if (word.startsWith(needleWord)) {
partialWordMatches++;
found = true;
break;
}
}
}
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)
);
}
// Check if needle appears at the start of any word
for (const word of words) {
if (word.startsWith(needle)) {
return 0.6;
}
}
return 0;
}
/**
* Score fuzzy matches with distance penalty
*/
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;
return Math.min(0.5, Math.max(0.1, score)); // Cap fuzzy scores at 0.5
}
/**
* Legacy fuzzy match function for backward compatibility
*/
export function fuzzyMatchString(needle: string, haystack: string): boolean {
const hlen = haystack.length;
const nlen = needle.length;

View file

@ -1,12 +1,12 @@
<script lang="ts">
import { setupConvex } from 'convex-svelte';
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { models } from '$lib/state/models.svelte';
import GlobalModal from '$lib/components/ui/modal/global-modal.svelte';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { goto } from '$app/navigation';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { shortcut } from '$lib/actions/shortcut.svelte';
import GlobalModal from '$lib/components/ui/modal/global-modal.svelte';
import { models } from '$lib/state/models.svelte';
import { setupConvex } from 'convex-svelte';
import { ModeWatcher } from 'mode-watcher';
import '../app.css';
let { children } = $props();

View file

@ -11,7 +11,7 @@
import XIcon from '~icons/lucide/x';
import PlusIcon from '~icons/lucide/plus';
import { models } from '$lib/state/models.svelte';
import fuzzysearch from '$lib/utils/fuzzy-search';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,

View file

@ -4,6 +4,7 @@
import { api } from '$lib/backend/convex/_generated/api.js';
import { type Doc, type Id } from '$lib/backend/convex/_generated/dataModel.js';
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import * as Icons from '$lib/components/icons';
import { Button } from '$lib/components/ui/button';
import { ImageModal } from '$lib/components/ui/image-modal';
@ -11,6 +12,7 @@
import * as Sidebar from '$lib/components/ui/sidebar';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js';
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
import { models } from '$lib/state/models.svelte';
import { usePrompt } from '$lib/state/prompt.svelte.js';
@ -20,30 +22,29 @@
import { compressImage } from '$lib/utils/image-compression';
import { supportsImages } from '$lib/utils/model-capabilities';
import { omit, pick } from '$lib/utils/object.js';
import { cn } from '$lib/utils/utils.js';
import { useConvexClient } from 'convex-svelte';
import { FileUpload, Popover } from 'melt/builders';
import { ResultAsync } from 'neverthrow';
import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed';
import { scale } from 'svelte/transition';
import SendIcon from '~icons/lucide/arrow-up';
import StopIcon from '~icons/lucide/square';
import CheckIcon from '~icons/lucide/check';
import ChevronDownIcon from '~icons/lucide/chevron-down';
import ImageIcon from '~icons/lucide/image';
import PanelLeftIcon from '~icons/lucide/panel-left';
import Settings2Icon from '~icons/lucide/settings-2';
import UploadIcon from '~icons/lucide/upload';
import XIcon from '~icons/lucide/x';
import SearchIcon from '~icons/lucide/search';
import { callGenerateMessage } from '../api/generate-message/call.js';
import { callCancelGeneration } from '../api/cancel-generation/call.js';
import ModelPicker from './model-picker.svelte';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import { cn } from '$lib/utils/utils.js';
import ShareIcon from '~icons/lucide/share';
import { Result, ResultAsync } from 'neverthrow';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte.js';
import { scale } from 'svelte/transition';
import CheckIcon from '~icons/lucide/check';
import LockIcon from '~icons/lucide/lock';
import LockOpenIcon from '~icons/lucide/lock-open';
import PanelLeftIcon from '~icons/lucide/panel-left';
import SearchIcon from '~icons/lucide/search';
import Settings2Icon from '~icons/lucide/settings-2';
import ShareIcon from '~icons/lucide/share';
import StopIcon from '~icons/lucide/square';
import UploadIcon from '~icons/lucide/upload';
import XIcon from '~icons/lucide/x';
import { callCancelGeneration } from '../api/cancel-generation/call.js';
import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte';
import SearchModal from './search-modal.svelte';
const client = useConvexClient();
@ -453,6 +454,7 @@
Share
</Tooltip>
{/if}
<SearchModal />
<Tooltip>
{#snippet trigger(tooltip)}
<Button variant="ghost" size="icon" class="size-8" href="/account" {...tooltip.trigger}>

View file

@ -10,7 +10,7 @@
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
import { Provider } from '$lib/types';
import fuzzysearch from '$lib/utils/fuzzy-search';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { supportsImages } from '$lib/utils/model-capabilities';
import { capitalize } from '$lib/utils/strings';
import { cn } from '$lib/utils/utils';

View file

@ -0,0 +1,210 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { Button } from '$lib/components/ui/button/index.js';
import Modal from '$lib/components/ui/modal/modal.svelte';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { session } from '$lib/state/session.svelte';
import { useQuery } from 'convex-svelte';
import { Debounced } from 'runed';
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import SearchIcon from '~icons/lucide/search';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte';
let open = $state(false);
let input = $state('');
let searchMode = $state<'exact' | 'words' | 'fuzzy'>('words');
let inputEl = $state<HTMLInputElement>();
let selectedIndex = $state(-1);
const debouncedInput = new Debounced(() => input, 500);
const search = useQuery(api.conversations.search, () => ({
search_term: debouncedInput.current,
search_mode: searchMode,
session_token: session.current?.session.token ?? '',
}));
// Reset selected index when search results change
$effect(() => {
if (search.data) {
selectedIndex = -1;
}
});
// Reset selected index when input changes
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
input; // Track input changes
selectedIndex = -1;
});
function handleKeydown(event: KeyboardEvent) {
if (!search.data?.length) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, search.data.length - 1);
scrollToSelected();
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
scrollToSelected();
break;
case 'Enter':
event.preventDefault();
if (selectedIndex >= 0 && selectedIndex < search.data.length) {
const result = search.data[selectedIndex];
if (result) {
goto(`/chat/${result.conversation._id}`);
open = false;
}
}
break;
case 'Escape':
event.preventDefault();
open = false;
break;
}
}
async function scrollToSelected() {
await tick();
const selectedElement = document.querySelector(`[data-result-index="${selectedIndex}"]`);
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
</script>
<svelte:window use:shortcut={{ ctrl: true, key: 'k', callback: () => (open = true) }} />
<Tooltip>
{#snippet trigger(tooltip)}
<Button
onclick={() => (open = true)}
variant="ghost"
size="icon"
class="size-8"
{...tooltip.trigger}
>
<SearchIcon class="!size-4" />
<span class="sr-only">Search</span>
</Button>
{/snippet}
Search ({cmdOrCtrl} + K)
</Tooltip>
<Modal bind:open>
<div class="space-y-4">
<h2 class="text-lg font-semibold">Search Conversations</h2>
<div class="space-y-3">
<input
bind:this={inputEl}
bind:value={input}
onkeydown={handleKeydown}
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..."
{@attach (node) => {
if (!open) return;
setTimeout(() => {
console.log('focus', node, open);
if (open) node.focus();
}, 50);
}}
/>
<div class="flex items-center gap-2">
<label for="search-mode" class="text-muted-foreground text-sm font-medium"
>Search mode:</label
>
<select
id="search-mode"
bind:value={searchMode}
class="border-input bg-background rounded border px-2 py-1 text-xs"
>
<option value="words">Word matching</option>
<option value="exact">Exact match</option>
<option value="fuzzy">Fuzzy search</option>
</select>
</div>
</div>
{#if search.isLoading}
<div class="flex justify-center py-8">
<div
class="size-6 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
</div>
{:else if search.data?.length}
<div class="max-h-96 space-y-2 overflow-y-auto">
{#each search.data as { conversation, messages, score, titleMatch }, index}
<div
data-result-index={index}
class="border-border flex cursor-pointer items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors {index ===
selectedIndex
? 'bg-accent'
: 'hover:bg-muted/50'}"
role="button"
tabindex="0"
onclick={() => {
goto(`/chat/${conversation._id}`);
open = false;
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/chat/${conversation._id}`);
open = false;
}
}}
onmouseenter={() => (selectedIndex = index)}
>
<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}
</div>
<div class="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-xs">
{Math.round(score * 100)}%
</div>
</div>
<div class="text-muted-foreground text-xs">
{messages.length} matching message{messages.length !== 1 ? 's' : ''}
{#if titleMatch}
<span class="text-heading">• Title match</span>
{/if}
</div>
</div>
<Button
variant="secondary"
size="sm"
class="shrink-0 text-xs"
onclick={(e: MouseEvent) => {
e.stopPropagation();
goto(`/chat/${conversation._id}`);
open = false;
}}
>
View
</Button>
</div>
{/each}
</div>
{:else if debouncedInput.current.trim()}
<div class="text-muted-foreground py-8 text-center">
<p>No results found for "{debouncedInput.current}"</p>
<p class="mt-1 text-xs">Try a different search term or mode</p>
</div>
{:else}
<div class="text-muted-foreground py-8 text-center">
<p>Start typing to search your conversations</p>
<p class="mt-1 text-xs">Use ↑↓ to navigate, Enter to select, Esc to close</p>
</div>
{/if}
</div>
</Modal>