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';
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9754 0.0084 325.6414);

View file

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

View file

@ -1,136 +1,136 @@
interface CacheNode<K, V> {
key: K;
value: V;
size: number;
prev: CacheNode<K, V> | null;
next: CacheNode<K, V> | null;
key: K;
value: V;
size: number;
prev: CacheNode<K, V> | null;
next: CacheNode<K, V> | null;
}
export class LRUCache<K = string, V = unknown> {
private capacity: number;
private currentSize = 0;
private cache = new Map<K, CacheNode<K, V>>();
private head: CacheNode<K, V> | null = null;
private tail: CacheNode<K, V> | null = null;
private capacity: number;
private currentSize = 0;
private cache = new Map<K, CacheNode<K, V>>();
private head: CacheNode<K, V> | null = null;
private tail: CacheNode<K, V> | null = null;
constructor(maxSizeBytes = 1024 * 1024) {
this.capacity = maxSizeBytes;
}
constructor(maxSizeBytes = 1024 * 1024) {
this.capacity = maxSizeBytes;
}
private calculateSize(value: V): number {
try {
return new Blob([JSON.stringify(value)]).size;
} catch {
return JSON.stringify(value).length * 2;
}
}
private calculateSize(value: V): number {
try {
return new Blob([JSON.stringify(value)]).size;
} catch {
return JSON.stringify(value).length * 2;
}
}
private removeNode(node: CacheNode<K, V>): void {
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
private removeNode(node: CacheNode<K, V>): void {
if (node.prev) {
node.prev.next = node.next;
} else {
this.head = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
}
if (node.next) {
node.next.prev = node.prev;
} else {
this.tail = node.prev;
}
}
private addToHead(node: CacheNode<K, V>): void {
node.prev = null;
node.next = this.head;
private addToHead(node: CacheNode<K, V>): void {
node.prev = null;
node.next = this.head;
if (this.head) {
this.head.prev = node;
}
if (this.head) {
this.head.prev = node;
}
this.head = node;
this.head = node;
if (!this.tail) {
this.tail = node;
}
}
if (!this.tail) {
this.tail = node;
}
}
private evictLRU(): void {
while (this.tail && this.currentSize > this.capacity) {
const lastNode = this.tail;
this.removeNode(lastNode);
this.cache.delete(lastNode.key);
this.currentSize -= lastNode.size;
}
}
private evictLRU(): void {
while (this.tail && this.currentSize > this.capacity) {
const lastNode = this.tail;
this.removeNode(lastNode);
this.cache.delete(lastNode.key);
this.currentSize -= lastNode.size;
}
}
get(key: K): V | undefined {
const node = this.cache.get(key);
if (!node) return undefined;
get(key: K): V | undefined {
const node = this.cache.get(key);
if (!node) return undefined;
this.removeNode(node);
this.addToHead(node);
this.removeNode(node);
this.addToHead(node);
return node.value;
}
return node.value;
}
set(key: K, value: V): void {
const size = this.calculateSize(value);
if (size > this.capacity) {
return;
}
set(key: K, value: V): void {
const size = this.calculateSize(value);
const existingNode = this.cache.get(key);
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,
};
if (size > this.capacity) {
return;
}
this.currentSize += size;
this.cache.set(key, newNode);
this.addToHead(newNode);
}
const existingNode = this.cache.get(key);
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 {
const node = this.cache.get(key);
if (!node) return false;
this.currentSize += size;
this.cache.set(key, newNode);
this.addToHead(newNode);
}
this.removeNode(node);
this.cache.delete(key);
this.currentSize -= node.size;
return true;
}
this.evictLRU();
}
clear(): void {
this.cache.clear();
this.head = null;
this.tail = null;
this.currentSize = 0;
}
delete(key: K): boolean {
const node = this.cache.get(key);
if (!node) return false;
get size(): number {
return this.cache.size;
}
this.removeNode(node);
this.cache.delete(key);
this.currentSize -= node.size;
return true;
}
get bytes(): number {
return this.currentSize;
}
clear(): void {
this.cache.clear();
this.head = null;
this.tail = null;
this.currentSize = 0;
}
has(key: K): boolean {
return this.cache.has(key);
}
}
get size(): number {
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';
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl?: number;
data: T;
timestamp: number;
ttl?: number;
}
export class SessionStorageCache<T = unknown> {
private memoryCache: LRUCache<string, CacheEntry<T>>;
private storageKey: string;
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
private debounceMs: number;
private pendingWrites = new Set<string>();
private memoryCache: LRUCache<string, CacheEntry<T>>;
private storageKey: string;
private writeTimeout: ReturnType<typeof setTimeout> | null = null;
private debounceMs: number;
private pendingWrites = new Set<string>();
constructor(
storageKey = 'query-cache',
maxSizeBytes = 1024 * 1024,
debounceMs = 300
) {
this.storageKey = storageKey;
this.debounceMs = debounceMs;
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
this.loadFromSessionStorage();
}
constructor(storageKey = 'query-cache', maxSizeBytes = 1024 * 1024, debounceMs = 300) {
this.storageKey = storageKey;
this.debounceMs = debounceMs;
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
this.loadFromSessionStorage();
}
private loadFromSessionStorage(): void {
try {
const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return;
private loadFromSessionStorage(): void {
try {
const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return;
const data = JSON.parse(stored) as Record<string, CacheEntry<T>>;
const now = Date.now();
const data = JSON.parse(stored) as Record<string, CacheEntry<T>>;
const now = Date.now();
for (const [key, entry] of Object.entries(data)) {
if (entry.ttl && now - entry.timestamp > entry.ttl) {
continue;
}
this.memoryCache.set(key, entry);
}
} catch (error) {
console.warn('Failed to load cache from sessionStorage:', error);
}
}
for (const [key, entry] of Object.entries(data)) {
if (entry.ttl && now - entry.timestamp > entry.ttl) {
continue;
}
this.memoryCache.set(key, entry);
}
} catch (error) {
console.warn('Failed to load cache from sessionStorage:', error);
}
}
private debouncedWrite(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
}
private debouncedWrite(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
}
this.writeTimeout = setTimeout(() => {
this.writeToSessionStorage();
this.writeTimeout = null;
}, this.debounceMs);
}
this.writeTimeout = setTimeout(() => {
this.writeToSessionStorage();
this.writeTimeout = null;
}, this.debounceMs);
}
private writeToSessionStorage(): void {
try {
const cacheData: Record<string, CacheEntry<T>> = {};
const now = Date.now();
private writeToSessionStorage(): void {
try {
const cacheData: Record<string, CacheEntry<T>> = {};
const now = Date.now();
for (const key of this.pendingWrites) {
const entry = this.memoryCache.get(key);
if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry;
}
}
for (const key of this.pendingWrites) {
const entry = this.memoryCache.get(key);
if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry;
}
}
const existingData = sessionStorage.getItem(this.storageKey);
if (existingData) {
const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>;
for (const [key, entry] of Object.entries(existing)) {
if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry;
}
}
}
const existingData = sessionStorage.getItem(this.storageKey);
if (existingData) {
const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>;
for (const [key, entry] of Object.entries(existing)) {
if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry;
}
}
}
sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData));
this.pendingWrites.clear();
} catch (error) {
console.warn('Failed to write cache to sessionStorage:', error);
}
}
sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData));
this.pendingWrites.clear();
} catch (error) {
console.warn('Failed to write cache to sessionStorage:', error);
}
}
get(key: string): T | undefined {
const entry = this.memoryCache.get(key);
if (!entry) return undefined;
get(key: string): T | undefined {
const entry = this.memoryCache.get(key);
if (!entry) return undefined;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return undefined;
}
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return undefined;
}
return entry.data;
}
return entry.data;
}
set(key: string, data: T, ttlMs?: number): void {
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
ttl: ttlMs,
};
set(key: string, data: T, ttlMs?: number): void {
const entry: CacheEntry<T> = {
data,
timestamp: Date.now(),
ttl: ttlMs,
};
this.memoryCache.set(key, entry);
this.pendingWrites.add(key);
this.debouncedWrite();
}
this.memoryCache.set(key, entry);
this.pendingWrites.add(key);
this.debouncedWrite();
}
delete(key: string): boolean {
const deleted = this.memoryCache.delete(key);
if (deleted) {
this.pendingWrites.add(key);
this.debouncedWrite();
}
return deleted;
}
delete(key: string): boolean {
const deleted = this.memoryCache.delete(key);
if (deleted) {
this.pendingWrites.add(key);
this.debouncedWrite();
}
return deleted;
}
clear(): void {
this.memoryCache.clear();
try {
sessionStorage.removeItem(this.storageKey);
} catch (error) {
console.warn('Failed to clear sessionStorage:', error);
}
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
this.pendingWrites.clear();
}
clear(): void {
this.memoryCache.clear();
try {
sessionStorage.removeItem(this.storageKey);
} catch (error) {
console.warn('Failed to clear sessionStorage:', error);
}
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
this.pendingWrites.clear();
}
has(key: string): boolean {
const entry = this.memoryCache.get(key);
if (!entry) return false;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return false;
}
return true;
}
has(key: string): boolean {
const entry = this.memoryCache.get(key);
if (!entry) return false;
get size(): number {
return this.memoryCache.size;
}
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key);
return false;
}
get bytes(): number {
return this.memoryCache.bytes;
}
return true;
}
forceWrite(): void {
if (this.writeTimeout) {
clearTimeout(this.writeTimeout);
this.writeTimeout = null;
}
this.writeToSessionStorage();
}
}
get size(): number {
return this.memoryCache.size;
}
get bytes(): number {
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';
export { Switch };
export { Switch };

View file

@ -1,6 +1,6 @@
import { redirect } from "@sveltejs/kit";
import { redirect } from '@sveltejs/kit';
export async function load() {
// 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 { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { Button } from '$lib/components/ui/button';
import { Search } from '$lib/components/ui/search';
import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js';
import { cn } from '$lib/utils/utils';
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();
@ -21,6 +25,23 @@
const hasOpenRouterKey = $derived(
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>
<svelte:head>
@ -32,28 +53,51 @@
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
<div class="mt-8 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,
})}
<div class="mt-4 flex flex-col gap-2">
<Search bind:value={search} placeholder="Search models" />
<div class="flex place-items-center gap-2">
<button
{...openRouterToggle.trigger}
aria-label="OpenRouter"
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"
>
{#each data.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}
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
</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,
"strict": true,
"moduleResolution": "bundler",
"types": [
"unplugin-icons/types/svelte"
]
"types": ["unplugin-icons/types/svelte"]
}
// 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