kepler-chat/src/routes/account/models/+page.svelte
Aidan Bleser 7b9595e571
Post Hackathon Stuff (#40)
Co-authored-by: Thomas G. Lopes <26071571+TGlide@users.noreply.github.com>
2025-07-10 04:45:02 -07:00

166 lines
5.5 KiB
Svelte

<script lang="ts">
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 { models } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { cn } from '$lib/utils/utils';
import { Toggle } from 'melt/builders';
import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x';
import ModelCard from './model-card.svelte';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '',
});
const hasOpenRouterKey = $derived(
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== ''
);
let search = $state('');
const openRouterToggle = new Toggle({
value: true,
// TODO: enable this if and when when we use multiple providers
disabled: true,
});
const freeModelsToggle = new Toggle({
value: false,
});
const reasoningModelsToggle = new Toggle({
value: false,
});
const imageModelsToggle = new Toggle({
value: false,
});
let initiallyEnabled = $state<string[]>([]);
$effect(() => {
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) {
initiallyEnabled = models
.from(Provider.OpenRouter)
.filter((m) => m.enabled)
.map((m) => m.id);
}
});
const openRouterModels = $derived(
fuzzysearch({
haystack: models.from(Provider.OpenRouter).filter((m) => {
if (freeModelsToggle.value) {
if (m.pricing.prompt !== '0') return false;
}
if (reasoningModelsToggle.value) {
if (!supportsReasoning(m)) return false;
}
if (imageModelsToggle.value) {
if (!supportsImages(m)) return false;
}
return true;
}),
needle: search,
property: 'name',
}).sort((a, b) => {
const aEnabled = initiallyEnabled.includes(a.id);
const bEnabled = initiallyEnabled.includes(b.id);
if (aEnabled && !bEnabled) return -1;
if (!aEnabled && bEnabled) return 1;
return 0;
})
);
</script>
<svelte:head>
<title>Models | thom.chat</title>
</svelte:head>
<h1 class="text-2xl font-bold">Available Models</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
<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"
>
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...freeModelsToggle.trigger}
aria-label="Free Models"
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"
>
Free
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...reasoningModelsToggle.trigger}
aria-label="Reasoning Models"
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"
>
Reasoning
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...imageModelsToggle.trigger}
aria-label="Image Models"
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"
>
Images
<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)}
<ModelCard
provider={Provider.OpenRouter}
{model}
enabled={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}