search functionality

This commit is contained in:
Aidan Bleser 2025-06-16 06:42:56 -05:00
parent d5dbab44fa
commit 1aaae9b8f8
11 changed files with 326 additions and 275 deletions

View file

@ -1,3 +1,3 @@
{ {
"functions": "src/lib/backend/convex" "functions": "src/lib/backend/convex"
} }

View file

@ -1,6 +1,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.9754 0.0084 325.6414); --background: oklch(0.9754 0.0084 325.6414);

View file

@ -93,4 +93,3 @@ export function clearQueryCache(): void {
} }
export { globalCache as queryCache }; export { globalCache as queryCache };

View file

@ -1,136 +1,136 @@
interface CacheNode<K, V> { interface CacheNode<K, V> {
key: K; key: K;
value: V; value: V;
size: number; size: number;
prev: CacheNode<K, V> | null; prev: CacheNode<K, V> | null;
next: CacheNode<K, V> | null; next: CacheNode<K, V> | null;
} }
export class LRUCache<K = string, V = unknown> { export class LRUCache<K = string, V = unknown> {
private capacity: number; private capacity: number;
private currentSize = 0; private currentSize = 0;
private cache = new Map<K, CacheNode<K, V>>(); private cache = new Map<K, CacheNode<K, V>>();
private head: CacheNode<K, V> | null = null; private head: CacheNode<K, V> | null = null;
private tail: CacheNode<K, V> | null = null; private tail: CacheNode<K, V> | null = null;
constructor(maxSizeBytes = 1024 * 1024) { constructor(maxSizeBytes = 1024 * 1024) {
this.capacity = maxSizeBytes; this.capacity = maxSizeBytes;
} }
private calculateSize(value: V): number { private calculateSize(value: V): number {
try { try {
return new Blob([JSON.stringify(value)]).size; return new Blob([JSON.stringify(value)]).size;
} catch { } catch {
return JSON.stringify(value).length * 2; return JSON.stringify(value).length * 2;
} }
} }
private removeNode(node: CacheNode<K, V>): void { private removeNode(node: CacheNode<K, V>): void {
if (node.prev) { if (node.prev) {
node.prev.next = node.next; node.prev.next = node.next;
} else { } else {
this.head = node.next; this.head = node.next;
} }
if (node.next) { if (node.next) {
node.next.prev = node.prev; node.next.prev = node.prev;
} else { } else {
this.tail = node.prev; this.tail = node.prev;
} }
} }
private addToHead(node: CacheNode<K, V>): void { private addToHead(node: CacheNode<K, V>): void {
node.prev = null; node.prev = null;
node.next = this.head; node.next = this.head;
if (this.head) { if (this.head) {
this.head.prev = node; this.head.prev = node;
} }
this.head = node; this.head = node;
if (!this.tail) { if (!this.tail) {
this.tail = node; this.tail = node;
} }
} }
private evictLRU(): void { private evictLRU(): void {
while (this.tail && this.currentSize > this.capacity) { while (this.tail && this.currentSize > this.capacity) {
const lastNode = this.tail; const lastNode = this.tail;
this.removeNode(lastNode); this.removeNode(lastNode);
this.cache.delete(lastNode.key); this.cache.delete(lastNode.key);
this.currentSize -= lastNode.size; this.currentSize -= lastNode.size;
} }
} }
get(key: K): V | undefined { get(key: K): V | undefined {
const node = this.cache.get(key); const node = this.cache.get(key);
if (!node) return undefined; if (!node) return undefined;
this.removeNode(node); this.removeNode(node);
this.addToHead(node); this.addToHead(node);
return node.value; return node.value;
} }
set(key: K, value: V): void { set(key: K, value: V): void {
const size = this.calculateSize(value); const size = this.calculateSize(value);
if (size > this.capacity) {
return;
}
const existingNode = this.cache.get(key); if (size > this.capacity) {
return;
if (existingNode) { }
existingNode.value = value;
this.currentSize = this.currentSize - existingNode.size + size;
existingNode.size = size;
this.removeNode(existingNode);
this.addToHead(existingNode);
} else {
const newNode: CacheNode<K, V> = {
key,
value,
size,
prev: null,
next: null,
};
this.currentSize += size; const existingNode = this.cache.get(key);
this.cache.set(key, newNode);
this.addToHead(newNode);
}
this.evictLRU(); if (existingNode) {
} existingNode.value = value;
this.currentSize = this.currentSize - existingNode.size + size;
existingNode.size = size;
this.removeNode(existingNode);
this.addToHead(existingNode);
} else {
const newNode: CacheNode<K, V> = {
key,
value,
size,
prev: null,
next: null,
};
delete(key: K): boolean { this.currentSize += size;
const node = this.cache.get(key); this.cache.set(key, newNode);
if (!node) return false; this.addToHead(newNode);
}
this.removeNode(node); this.evictLRU();
this.cache.delete(key); }
this.currentSize -= node.size;
return true;
}
clear(): void { delete(key: K): boolean {
this.cache.clear(); const node = this.cache.get(key);
this.head = null; if (!node) return false;
this.tail = null;
this.currentSize = 0;
}
get size(): number { this.removeNode(node);
return this.cache.size; this.cache.delete(key);
} this.currentSize -= node.size;
return true;
}
get bytes(): number { clear(): void {
return this.currentSize; this.cache.clear();
} this.head = null;
this.tail = null;
this.currentSize = 0;
}
has(key: K): boolean { get size(): number {
return this.cache.has(key); return this.cache.size;
} }
}
get bytes(): number {
return this.currentSize;
}
has(key: K): boolean {
return this.cache.has(key);
}
}

View file

@ -1,160 +1,156 @@
import { LRUCache } from './lru-cache.js'; import { LRUCache } from './lru-cache.js';
interface CacheEntry<T> { interface CacheEntry<T> {
data: T; data: T;
timestamp: number; timestamp: number;
ttl?: number; ttl?: number;
} }
export class SessionStorageCache<T = unknown> { export class SessionStorageCache<T = unknown> {
private memoryCache: LRUCache<string, CacheEntry<T>>; private memoryCache: LRUCache<string, CacheEntry<T>>;
private storageKey: string; private storageKey: string;
private writeTimeout: ReturnType<typeof setTimeout> | null = null; private writeTimeout: ReturnType<typeof setTimeout> | null = null;
private debounceMs: number; private debounceMs: number;
private pendingWrites = new Set<string>(); private pendingWrites = new Set<string>();
constructor( constructor(storageKey = 'query-cache', maxSizeBytes = 1024 * 1024, debounceMs = 300) {
storageKey = 'query-cache', this.storageKey = storageKey;
maxSizeBytes = 1024 * 1024, this.debounceMs = debounceMs;
debounceMs = 300 this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
) { this.loadFromSessionStorage();
this.storageKey = storageKey; }
this.debounceMs = debounceMs;
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
this.loadFromSessionStorage();
}
private loadFromSessionStorage(): void { private loadFromSessionStorage(): void {
try { try {
const stored = sessionStorage.getItem(this.storageKey); const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return; if (!stored) return;
const data = JSON.parse(stored) as Record<string, CacheEntry<T>>; const data = JSON.parse(stored) as Record<string, CacheEntry<T>>;
const now = Date.now(); const now = Date.now();
for (const [key, entry] of Object.entries(data)) { for (const [key, entry] of Object.entries(data)) {
if (entry.ttl && now - entry.timestamp > entry.ttl) { if (entry.ttl && now - entry.timestamp > entry.ttl) {
continue; continue;
} }
this.memoryCache.set(key, entry); this.memoryCache.set(key, entry);
} }
} catch (error) { } catch (error) {
console.warn('Failed to load cache from sessionStorage:', error); console.warn('Failed to load cache from sessionStorage:', error);
} }
} }
private debouncedWrite(): void { private debouncedWrite(): void {
if (this.writeTimeout) { if (this.writeTimeout) {
clearTimeout(this.writeTimeout); clearTimeout(this.writeTimeout);
} }
this.writeTimeout = setTimeout(() => { this.writeTimeout = setTimeout(() => {
this.writeToSessionStorage(); this.writeToSessionStorage();
this.writeTimeout = null; this.writeTimeout = null;
}, this.debounceMs); }, this.debounceMs);
} }
private writeToSessionStorage(): void { private writeToSessionStorage(): void {
try { try {
const cacheData: Record<string, CacheEntry<T>> = {}; const cacheData: Record<string, CacheEntry<T>> = {};
const now = Date.now(); const now = Date.now();
for (const key of this.pendingWrites) { for (const key of this.pendingWrites) {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) { if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry; cacheData[key] = entry;
} }
} }
const existingData = sessionStorage.getItem(this.storageKey); const existingData = sessionStorage.getItem(this.storageKey);
if (existingData) { if (existingData) {
const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>; const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>;
for (const [key, entry] of Object.entries(existing)) { for (const [key, entry] of Object.entries(existing)) {
if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) { if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry; cacheData[key] = entry;
} }
} }
} }
sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData)); sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData));
this.pendingWrites.clear(); this.pendingWrites.clear();
} catch (error) { } catch (error) {
console.warn('Failed to write cache to sessionStorage:', error); console.warn('Failed to write cache to sessionStorage:', error);
} }
} }
get(key: string): T | undefined { get(key: string): T | undefined {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (!entry) return undefined; if (!entry) return undefined;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key); this.delete(key);
return undefined; return undefined;
} }
return entry.data; return entry.data;
} }
set(key: string, data: T, ttlMs?: number): void { set(key: string, data: T, ttlMs?: number): void {
const entry: CacheEntry<T> = { const entry: CacheEntry<T> = {
data, data,
timestamp: Date.now(), timestamp: Date.now(),
ttl: ttlMs, ttl: ttlMs,
}; };
this.memoryCache.set(key, entry); this.memoryCache.set(key, entry);
this.pendingWrites.add(key); this.pendingWrites.add(key);
this.debouncedWrite(); this.debouncedWrite();
} }
delete(key: string): boolean { delete(key: string): boolean {
const deleted = this.memoryCache.delete(key); const deleted = this.memoryCache.delete(key);
if (deleted) { if (deleted) {
this.pendingWrites.add(key); this.pendingWrites.add(key);
this.debouncedWrite(); this.debouncedWrite();
} }
return deleted; return deleted;
} }
clear(): void { clear(): void {
this.memoryCache.clear(); this.memoryCache.clear();
try { try {
sessionStorage.removeItem(this.storageKey); sessionStorage.removeItem(this.storageKey);
} catch (error) { } catch (error) {
console.warn('Failed to clear sessionStorage:', error); console.warn('Failed to clear sessionStorage:', error);
} }
if (this.writeTimeout) { if (this.writeTimeout) {
clearTimeout(this.writeTimeout); clearTimeout(this.writeTimeout);
this.writeTimeout = null; this.writeTimeout = null;
} }
this.pendingWrites.clear(); this.pendingWrites.clear();
} }
has(key: string): boolean { has(key: string): boolean {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (!entry) return false; if (!entry) return false;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return false;
}
return true;
}
get size(): number { if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
return this.memoryCache.size; this.delete(key);
} return false;
}
get bytes(): number { return true;
return this.memoryCache.bytes; }
}
forceWrite(): void { get size(): number {
if (this.writeTimeout) { return this.memoryCache.size;
clearTimeout(this.writeTimeout); }
this.writeTimeout = null;
} get bytes(): number {
this.writeToSessionStorage(); return this.memoryCache.bytes;
} }
}
forceWrite(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
this.writeToSessionStorage();
}
}

View file

@ -0,0 +1 @@
export { default as Search } from './search.svelte';

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import Search from '~icons/lucide/search';
let { value = $bindable(''), ...rest }: HTMLInputAttributes = $props();
</script>
<div
class="border-input focus-within:ring-ring ring-offset-background relative flex h-9 items-center rounded-md border p-2 text-base ring-offset-2 focus-within:ring-2 md:text-sm"
>
<Search class="text-muted-foreground size-4" />
<input {...rest} bind:value type="text" class="flex-1 bg-transparent px-2 outline-none" />
</div>

View file

@ -1,3 +1,3 @@
import Switch from './switch.svelte'; import Switch from './switch.svelte';
export { Switch }; export { Switch };

View file

@ -1,6 +1,6 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from '@sveltejs/kit';
export async function load() { export async function load() {
// temporary redirect to /chat // temporary redirect to /chat
redirect(303, '/chat'); redirect(303, '/chat');
} }

View file

@ -2,10 +2,14 @@
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte'; import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Search } from '$lib/components/ui/search';
import { session } from '$lib/state/session.svelte'; import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js'; import { Provider } from '$lib/types.js';
import { cn } from '$lib/utils/utils'; import { cn } from '$lib/utils/utils';
import ModelCard from './model-card.svelte'; import ModelCard from './model-card.svelte';
import { Toggle } from 'melt/builders';
import XIcon from '~icons/lucide/x';
import PlusIcon from '~icons/lucide/plus';
let { data } = $props(); let { data } = $props();
@ -21,6 +25,23 @@
const hasOpenRouterKey = $derived( const hasOpenRouterKey = $derived(
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== '' openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== ''
); );
let search = $state('');
const openRouterModels = $derived(
data.openRouterModels.filter((model) => {
if (search !== '' && !hasOpenRouterKey) return false;
if (!openRouterToggle.value) return false;
return model.name.toLowerCase().includes(search.toLowerCase());
})
);
const openRouterToggle = new Toggle({
value: true,
// TODO: enable this if and when when we use multiple providers
disabled: true,
});
</script> </script>
<svelte:head> <svelte:head>
@ -32,28 +53,51 @@
Choose which models appear in your model selector. This won't affect existing conversations. Choose which models appear in your model selector. This won't affect existing conversations.
</h2> </h2>
<div class="mt-8 flex flex-col gap-4"> <div class="mt-4 flex flex-col gap-2">
<div> <Search bind:value={search} placeholder="Search models" />
<h3 class="text-lg font-bold">OpenRouter</h3> <div class="flex place-items-center gap-2">
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p> <button
</div> {...openRouterToggle.trigger}
<div class="relative"> aria-label="OpenRouter"
<div class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
class={cn('flex flex-col gap-4 overflow-hidden', {
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
})}
> >
{#each data.openRouterModels as model (model.id)} OpenRouter
{@const enabled = enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined} <XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<ModelCard provider={Provider.OpenRouter} {model} {enabled} disabled={!hasOpenRouterKey} /> <PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
{/each} </button>
</div>
{#if !hasOpenRouterKey}
<div
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
>
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
</div>
{/if}
</div> </div>
</div> </div>
{#if openRouterModels.length > 0}
<div class="mt-4 flex flex-col gap-4">
<div>
<h3 class="text-lg font-bold">OpenRouter</h3>
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p>
</div>
<div class="relative">
<div
class={cn('flex flex-col gap-4 overflow-hidden', {
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
})}
>
{#each openRouterModels as model (model.id)}
{@const enabled =
enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined}
<ModelCard
provider={Provider.OpenRouter}
{model}
{enabled}
disabled={!hasOpenRouterKey}
/>
{/each}
</div>
{#if !hasOpenRouterKey}
<div
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
>
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -10,9 +10,7 @@
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"types": [ "types": ["unplugin-icons/types/svelte"]
"unplugin-icons/types/svelte"
]
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files