model picker (#17)

This commit is contained in:
Aidan Bleser 2025-06-18 08:33:15 -05:00 committed by GitHub
parent f47b965c48
commit 687b6b9818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 437 additions and 316 deletions

View file

@ -37,10 +37,12 @@ export const get = query({
export const getById = query({ export const getById = query({
args: { args: {
conversation_id: v.id('conversations'), conversation_id: v.optional(v.id('conversations')),
session_token: v.string(), session_token: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
if (!args.conversation_id) return null;
const session = await ctx.runQuery(api.betterAuth.publicGetSession, { const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token, session_token: args.session_token,
}); });
@ -90,11 +92,15 @@ export const createAndAddMessage = mutation({
content_html: v.optional(v.string()), content_html: v.optional(v.string()),
role: messageRoleValidator, role: messageRoleValidator,
session_token: v.string(), session_token: v.string(),
images: v.optional(v.array(v.object({ images: v.optional(
v.array(
v.object({
url: v.string(), url: v.string(),
storage_id: v.string(), storage_id: v.string(),
fileName: v.optional(v.string()), fileName: v.optional(v.string()),
}))), })
)
),
}, },
handler: async ( handler: async (
ctx, ctx,

View file

@ -42,11 +42,15 @@ export const create = mutation({
provider: v.optional(providerValidator), provider: v.optional(providerValidator),
token_count: v.optional(v.number()), token_count: v.optional(v.number()),
// Optional image attachments // Optional image attachments
images: v.optional(v.array(v.object({ images: v.optional(
v.array(
v.object({
url: v.string(), url: v.string(),
storage_id: v.string(), storage_id: v.string(),
fileName: v.optional(v.string()), fileName: v.optional(v.string()),
}))), })
)
),
}, },
handler: async (ctx, args): Promise<Id<'messages'>> => { handler: async (ctx, args): Promise<Id<'messages'>> => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, { const session = await ctx.runQuery(api.betterAuth.publicGetSession, {

View file

@ -55,4 +55,3 @@ export const deleteFile = mutation({
await ctx.storage.delete(args.storage_id); await ctx.storage.delete(args.storage_id);
}, },
}); });

View file

@ -0,0 +1,253 @@
<script lang="ts">
import { Sidebar, useSidebarControls } from '$lib/components/ui/sidebar';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { cn } from '$lib/utils/utils.js';
import { Avatar } from 'melt/components';
import LoaderCircleIcon from '~icons/lucide/loader-circle';
import PinOffIcon from '~icons/lucide/pin-off';
import { useConvexClient } from 'convex-svelte';
import { session } from '$lib/state/session.svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { callModal } from './ui/modal/global-modal.svelte';
import { goto } from '$app/navigation';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import PinIcon from '~icons/lucide/pin';
import XIcon from '~icons/lucide/x';
import { page } from '$app/state';
import { Button } from './ui/button';
const client = useConvexClient();
const controls = useSidebarControls();
async function togglePin(conversationId: string) {
if (!session.current?.session.token) return;
await client.mutation(api.conversations.togglePin, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.current.session.token,
});
}
async function deleteConversation(conversationId: string) {
const res = await callModal({
title: 'Delete conversation',
description: 'Are you sure you want to delete this conversation?',
actions: { cancel: 'outline', delete: 'destructive' },
});
if (res !== 'delete') return;
if (!session.current?.session.token) return;
await client.mutation(api.conversations.remove, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.current.session.token,
});
await goto(`/chat`);
}
const conversationsQuery = useCachedQuery(api.conversations.get, {
session_token: session.current?.session.token ?? '',
});
function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const groups = {
pinned: [] as Doc<'conversations'>[],
today: [] as Doc<'conversations'>[],
yesterday: [] as Doc<'conversations'>[],
lastWeek: [] as Doc<'conversations'>[],
lastMonth: [] as Doc<'conversations'>[],
older: [] as Doc<'conversations'>[],
};
conversations.forEach((conversation) => {
// Pinned conversations go to pinned group regardless of time
if (conversation.pinned) {
groups.pinned.push(conversation);
return;
}
const updatedAt = conversation.updated_at ?? 0;
const timeDiff = now - updatedAt;
if (timeDiff < oneDay) {
groups.today.push(conversation);
} else if (timeDiff < 2 * oneDay) {
groups.yesterday.push(conversation);
} else if (timeDiff < sevenDays) {
groups.lastWeek.push(conversation);
} else if (timeDiff < thirtyDays) {
groups.lastMonth.push(conversation);
} else {
groups.older.push(conversation);
}
});
// Sort pinned conversations by updated_at (most recent first)
groups.pinned.sort((a, b) => {
const aTime = a.updated_at ?? 0;
const bTime = b.updated_at ?? 0;
return bTime - aTime;
});
return groups;
}
const groupedConversations = $derived(groupConversationsByTime(conversationsQuery.data ?? []));
const templateConversations = $derived([
{ key: 'pinned', label: 'Pinned', conversations: groupedConversations.pinned, icon: PinIcon },
{ key: 'today', label: 'Today', conversations: groupedConversations.today },
{ key: 'yesterday', label: 'Yesterday', conversations: groupedConversations.yesterday },
{ key: 'lastWeek', label: 'Last 7 days', conversations: groupedConversations.lastWeek },
{ key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth },
{ key: 'older', label: 'Older', conversations: groupedConversations.older },
]);
</script>
<Sidebar class="flex flex-col overflow-clip p-2">
<div class="flex place-items-center justify-center py-2">
<span class="text-center font-serif text-lg">Thom.chat</span>
</div>
<div class="mt-1 flex w-full px-2">
<Tooltip>
{#snippet trigger(tooltip)}
<a
href="/chat"
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
{...tooltip.trigger}
onclick={controls.closeMobile}
style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
{/snippet}
{cmdOrCtrl} + Shift + O
</Tooltip>
</div>
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
<div
class="from-sidebar pointer-events-none absolute top-0 right-0 left-0 z-10 h-4 bg-gradient-to-b to-transparent"
></div>
<div class="flex flex-1 flex-col overflow-y-auto py-2">
{#each templateConversations as group, index (group.key)}
{@const IconComponent = group.icon}
{#if group.conversations.length > 0}
<div class="px-2 py-1" class:mt-2={index > 0}>
<h3 class="text-heading text-xs font-medium">
{#if IconComponent}
<IconComponent class="inline size-3" />
{/if}
{group.label}
</h3>
</div>
{#each group.conversations as conversation (conversation._id)}
{@const isActive = page.params.id === conversation._id}
<a
href={`/chat/${conversation._id}`}
onclick={controls.closeMobile}
class="group w-full py-0.5 pr-2.5 text-left text-sm"
>
<div
class={cn(
'relative flex w-full items-center justify-between overflow-clip rounded-lg',
{ 'bg-sidebar-accent': isActive, 'group-hover:bg-sidebar-accent': !isActive }
)}
>
<p class="truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap">
<span>{conversation.title}</span>
</p>
<div class="pr-2">
{#if conversation.generating}
<div
class="flex animate-[spin_0.75s_linear_infinite] place-items-center justify-center"
>
<LoaderCircleIcon class="size-4" />
</div>
{/if}
</div>
<div
class={[
'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0',
'to-sidebar-accent via-sidebar-accent bg-gradient-to-r from-transparent from-10% via-21% ',
]}
>
<Tooltip>
{#snippet trigger(tooltip)}
<button
{...tooltip.trigger}
class="hover:bg-muted rounded-md p-1"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
togglePin(conversation._id);
}}
>
{#if conversation.pinned}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</button>
{/snippet}
{conversation.pinned ? 'Unpin thread' : 'Pin thread'}
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<button
{...tooltip.trigger}
class="hover:bg-muted rounded-md p-1"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteConversation(conversation._id);
}}
>
<XIcon class="size-4" />
</button>
{/snippet}
Delete thread
</Tooltip>
</div>
</div>
</a>
{/each}
{/if}
{/each}
</div>
<div
class="from-sidebar pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-4 bg-gradient-to-t to-transparent"
></div>
</div>
<div class="py-2">
{#if page.data.session !== null}
<Button href="/account" variant="ghost" class="h-auto w-full justify-start">
<Avatar src={page.data.session?.user.image ?? undefined}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback} class="size-10 rounded-full">
{page.data.session?.user.name
.split(' ')
.map((name: string) => name[0]?.toUpperCase())
.join('')}
</span>
{/snippet}
</Avatar>
<div class="flex flex-col">
<span class="text-sm">{page.data.session?.user.name}</span>
<span class="text-muted-foreground text-xs">{page.data.session?.user.email}</span>
</div>
</Button>
{:else}
<Button href="/login" class="w-full">Login</Button>
{/if}
</div>
</Sidebar>

View file

@ -2,5 +2,6 @@ import Root from './sidebar.svelte';
import Sidebar from './sidebar-sidebar.svelte'; import Sidebar from './sidebar-sidebar.svelte';
import Inset from './sidebar-inset.svelte'; import Inset from './sidebar-inset.svelte';
import Trigger from './sidebar-trigger.svelte'; import Trigger from './sidebar-trigger.svelte';
import { useSidebarControls } from './sidebar.svelte.js';
export { Root, Sidebar, Inset, Trigger }; export { Root, Sidebar, Inset, Trigger, useSidebarControls };

View file

@ -19,6 +19,12 @@ export class SidebarRootState {
this.open = !this.open; this.open = !this.open;
} }
} }
closeMobile() {
if (this.isMobile.current) {
this.openMobile = false;
}
}
} }
export class SidebarTriggerState { export class SidebarTriggerState {
@ -35,6 +41,16 @@ export class SidebarSidebarState {
constructor(readonly root: SidebarRootState) {} constructor(readonly root: SidebarRootState) {}
} }
export class SidebarControlState {
constructor(readonly root: SidebarRootState) {
this.closeMobile = this.closeMobile.bind(this);
}
closeMobile() {
this.root.closeMobile();
}
}
export const ctx = new Context<SidebarRootState>('sidebar-root-context'); export const ctx = new Context<SidebarRootState>('sidebar-root-context');
export function useSidebar() { export function useSidebar() {
@ -48,3 +64,7 @@ export function useSidebarTrigger() {
export function useSidebarSidebar() { export function useSidebarSidebar() {
return new SidebarSidebarState(ctx.get()); return new SidebarSidebarState(ctx.get());
} }
export function useSidebarControls() {
return new SidebarControlState(ctx.get());
}

View file

@ -27,6 +27,16 @@ export function fromMap<K, V, T>(map: Map<K, V>, fn: (key: K, value: V) => T): T
return items; return items;
} }
export function fromRecord<V, T>(map: Record<string, V>, fn: (key: string, value: V) => T): T[] {
const items: T[] = [];
for (const [key, value] of Object.entries(map)) {
items.push(fn(key, value));
}
return items;
}
/** Calculates the sum of all elements in the array based on the provided function. /** Calculates the sum of all elements in the array based on the provided function.
* *
* @param arr Array of items to be summed. * @param arr Array of items to be summed.

View file

@ -52,7 +52,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title> <Card.Title>{model.name}</Card.Title>
<span class="text-muted-foreground text-xs hidden xl:block">{model.id}</span> <span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span>
</div> </div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} /> <Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div> </div>

View file

@ -8,7 +8,6 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ImageModal } from '$lib/components/ui/image-modal'; import { ImageModal } from '$lib/components/ui/image-modal';
import { LightSwitch } from '$lib/components/ui/light-switch/index.js'; import { LightSwitch } from '$lib/components/ui/light-switch/index.js';
import { callModal } from '$lib/components/ui/modal/global-modal.svelte';
import * as Sidebar from '$lib/components/ui/sidebar'; import * as Sidebar from '$lib/components/ui/sidebar';
import Tooltip from '$lib/components/ui/tooltip.svelte'; import Tooltip from '$lib/components/ui/tooltip.svelte';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js'; import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
@ -22,29 +21,25 @@
import { isString } from '$lib/utils/is.js'; import { isString } from '$lib/utils/is.js';
import { supportsImages } from '$lib/utils/model-capabilities'; import { supportsImages } from '$lib/utils/model-capabilities';
import { omit, pick } from '$lib/utils/object.js'; import { omit, pick } from '$lib/utils/object.js';
import { cn } from '$lib/utils/utils.js';
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { FileUpload, Popover } from 'melt/builders'; import { FileUpload, Popover } from 'melt/builders';
import { Avatar } from 'melt/components'; import { Debounced, ElementSize, IsMounted, PersistedState, ScrollState } from 'runed';
import { Debounced, ElementSize, IsMounted, ScrollState } from 'runed';
import SendIcon from '~icons/lucide/arrow-up'; import SendIcon from '~icons/lucide/arrow-up';
import StopIcon from '~icons/lucide/square'; import StopIcon from '~icons/lucide/square';
import ChevronDownIcon from '~icons/lucide/chevron-down'; import ChevronDownIcon from '~icons/lucide/chevron-down';
import ImageIcon from '~icons/lucide/image'; import ImageIcon from '~icons/lucide/image';
import LoaderCircleIcon from '~icons/lucide/loader-circle';
import PanelLeftIcon from '~icons/lucide/panel-left'; import PanelLeftIcon from '~icons/lucide/panel-left';
import PinIcon from '~icons/lucide/pin';
import PinOffIcon from '~icons/lucide/pin-off';
import Settings2Icon from '~icons/lucide/settings-2'; import Settings2Icon from '~icons/lucide/settings-2';
import UploadIcon from '~icons/lucide/upload'; import UploadIcon from '~icons/lucide/upload';
import XIcon from '~icons/lucide/x'; import XIcon from '~icons/lucide/x';
import { callGenerateMessage } from '../api/generate-message/call.js'; import { callGenerateMessage } from '../api/generate-message/call.js';
import { callCancelGeneration } from '../api/cancel-generation/call.js'; import { callCancelGeneration } from '../api/cancel-generation/call.js';
import ModelPicker from './model-picker.svelte'; import ModelPicker from './model-picker.svelte';
import AppSidebar from '$lib/components/app-sidebar.svelte';
const client = useConvexClient(); const client = useConvexClient();
let { data, children } = $props(); let { children } = $props();
let form = $state<HTMLFormElement>(); let form = $state<HTMLFormElement>();
let textarea = $state<HTMLTextAreaElement>(); let textarea = $state<HTMLTextAreaElement>();
@ -118,10 +113,6 @@
} }
} }
const conversationsQuery = useCachedQuery(api.conversations.get, {
session_token: session.current?.session.token ?? '',
});
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter, provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
@ -133,93 +124,7 @@
const autosize = new TextareaAutosize(); const autosize = new TextareaAutosize();
function groupConversationsByTime(conversations: Doc<'conversations'>[]) { const message = new PersistedState('prompt', '');
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const groups = {
pinned: [] as Doc<'conversations'>[],
today: [] as Doc<'conversations'>[],
yesterday: [] as Doc<'conversations'>[],
lastWeek: [] as Doc<'conversations'>[],
lastMonth: [] as Doc<'conversations'>[],
older: [] as Doc<'conversations'>[],
};
conversations.forEach((conversation) => {
// Pinned conversations go to pinned group regardless of time
if (conversation.pinned) {
groups.pinned.push(conversation);
return;
}
const updatedAt = conversation.updated_at ?? 0;
const timeDiff = now - updatedAt;
if (timeDiff < oneDay) {
groups.today.push(conversation);
} else if (timeDiff < 2 * oneDay) {
groups.yesterday.push(conversation);
} else if (timeDiff < sevenDays) {
groups.lastWeek.push(conversation);
} else if (timeDiff < thirtyDays) {
groups.lastMonth.push(conversation);
} else {
groups.older.push(conversation);
}
});
// Sort pinned conversations by updated_at (most recent first)
groups.pinned.sort((a, b) => {
const aTime = a.updated_at ?? 0;
const bTime = b.updated_at ?? 0;
return bTime - aTime;
});
return groups;
}
const groupedConversations = $derived(groupConversationsByTime(conversationsQuery.data ?? []));
async function togglePin(conversationId: string) {
if (!session.current?.session.token) return;
await client.mutation(api.conversations.togglePin, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.current.session.token,
});
}
async function deleteConversation(conversationId: string) {
const res = await callModal({
title: 'Delete conversation',
description: 'Are you sure you want to delete this conversation?',
actions: { cancel: 'outline', delete: 'destructive' },
});
if (res !== 'delete') return;
if (!session.current?.session.token) return;
await client.mutation(api.conversations.remove, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.current.session.token,
});
goto(`/chat`);
}
const templateConversations = $derived([
{ key: 'pinned', label: 'Pinned', conversations: groupedConversations.pinned, icon: PinIcon },
{ key: 'today', label: 'Today', conversations: groupedConversations.today },
{ key: 'yesterday', label: 'Yesterday', conversations: groupedConversations.yesterday },
{ key: 'lastWeek', label: 'Last 7 days', conversations: groupedConversations.lastWeek },
{ key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth },
{ key: 'older', label: 'Older', conversations: groupedConversations.older },
]);
let message = $state('');
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]); let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
let isUploading = $state(false); let isUploading = $state(false);
let fileInput = $state<HTMLInputElement>(); let fileInput = $state<HTMLInputElement>();
@ -230,8 +135,8 @@
}); });
usePrompt( usePrompt(
() => message, () => message.current,
(v) => (message = v) (v) => (message.current = v)
); );
models.init(); models.init();
@ -327,7 +232,7 @@
const cursor = textarea.selectionStart; const cursor = textarea.selectionStart;
const index = message.lastIndexOf('@', cursor); const index = message.current.lastIndexOf('@', cursor);
if (index === -1) return; if (index === -1) return;
const ruleFromCursor = message.slice(index + 1, cursor); const ruleFromCursor = message.slice(index + 1, cursor);
@ -357,10 +262,11 @@
const cursor = textarea.selectionStart; const cursor = textarea.selectionStart;
const index = message.lastIndexOf('@', cursor); const index = message.current.lastIndexOf('@', cursor);
if (index === -1) return; if (index === -1) return;
message = message.slice(0, index) + `@${rule.name}` + message.slice(cursor); message.current =
message.current.slice(0, index) + `@${rule.name}` + message.current.slice(cursor);
textarea.selectionStart = index + rule.name.length + 1; textarea.selectionStart = index + rule.name.length + 1;
textarea.selectionEnd = index + rule.name.length + 1; textarea.selectionEnd = index + rule.name.length + 1;
@ -447,144 +353,9 @@
class="h-screen overflow-clip" class="h-screen overflow-clip"
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}} {...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
> >
<Sidebar.Sidebar class="flex flex-col overflow-clip p-2"> <AppSidebar />
<div class="flex place-items-center justify-center py-2">
<span class="text-center font-serif text-lg">Thom.chat</span>
</div>
<div class="mt-1 flex w-full px-2">
<Tooltip>
{#snippet trigger(tooltip)}
<a
href="/chat"
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
{...tooltip.trigger}
style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
{/snippet}
{cmdOrCtrl} + Shift + O
</Tooltip>
</div>
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
<div
class="from-sidebar pointer-events-none absolute top-0 right-0 left-0 z-10 h-4 bg-gradient-to-b to-transparent"
></div>
<div class="flex flex-1 flex-col overflow-y-auto py-2">
{#each templateConversations as group, index (group.key)}
{@const IconComponent = group.icon}
{#if group.conversations.length > 0}
<div class="px-2 py-1" class:mt-2={index > 0}>
<h3 class="text-heading text-xs font-medium">
{#if IconComponent}
<IconComponent class="inline size-3" />
{/if}
{group.label}
</h3>
</div>
{#each group.conversations as conversation (conversation._id)}
{@const isActive = page.params.id === conversation._id}
<a
href={`/chat/${conversation._id}`}
class="group w-full py-0.5 pr-2.5 text-left text-sm"
>
<div
class={cn(
'relative flex w-full items-center justify-between overflow-clip rounded-lg',
{ 'bg-sidebar-accent': isActive, 'group-hover:bg-sidebar-accent': !isActive }
)}
>
<p class="truncate rounded-lg py-2 pr-4 pl-3 whitespace-nowrap">
<span>{conversation.title}</span>
</p>
<div class="pr-2">
{#if conversation.generating}
<div
class="flex animate-[spin_0.75s_linear_infinite] place-items-center justify-center"
>
<LoaderCircleIcon class="size-4" />
</div>
{/if}
</div>
<div
class={[
'pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg pr-2 pl-6 transition group-hover:pointer-events-auto group-hover:translate-0',
'to-sidebar-accent via-sidebar-accent bg-gradient-to-r from-transparent from-10% via-21% ',
]}
>
<Tooltip>
{#snippet trigger(tooltip)}
<button
{...tooltip.trigger}
class="hover:bg-muted rounded-md p-1"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
togglePin(conversation._id);
}}
>
{#if conversation.pinned}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</button>
{/snippet}
{conversation.pinned ? 'Unpin thread' : 'Pin thread'}
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<button
{...tooltip.trigger}
class="hover:bg-muted rounded-md p-1"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteConversation(conversation._id);
}}
>
<XIcon class="size-4" />
</button>
{/snippet}
Delete thread
</Tooltip>
</div>
</div>
</a>
{/each}
{/if}
{/each}
</div>
<div
class="from-sidebar pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-4 bg-gradient-to-t to-transparent"
></div>
</div>
<div class="py-2">
{#if data.session !== null}
<Button href="/account" variant="ghost" class="h-auto w-full justify-start">
<Avatar src={data.session?.user.image ?? undefined}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback} class="size-10 rounded-full">
{data.session?.user.name
.split(' ')
.map((name) => name[0]?.toUpperCase())
.join('')}
</span>
{/snippet}
</Avatar>
<div class="flex flex-col">
<span class="text-sm">{data.session?.user.name}</span>
<span class="text-muted-foreground text-xs">{data.session?.user.email}</span>
</div>
</Button>
{:else}
<Button href="/login" class="w-full">Login</Button>
{/if}
</div>
</Sidebar.Sidebar>
<Sidebar.Inset class="w-full overflow-clip "> <Sidebar.Inset class="w-full overflow-clip px-2">
<Tooltip> <Tooltip>
{#snippet trigger(tooltip)} {#snippet trigger(tooltip)}
<Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}> <Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
@ -759,7 +530,7 @@
popover.open = true; popover.open = true;
} }
}} }}
bind:value={message} bind:value={message.current}
autofocus autofocus
autocomplete="off" autocomplete="off"
{@attach autosize.attachment} {@attach autosize.attachment}
@ -772,7 +543,7 @@
<button <button
type={isGenerating ? 'button' : 'submit'} type={isGenerating ? 'button' : 'submit'}
onclick={isGenerating ? stopGeneration : undefined} onclick={isGenerating ? stopGeneration : undefined}
disabled={isGenerating ? false : !message.trim()} disabled={isGenerating ? false : !message.current.trim()}
class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50" class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50"
{...tooltip.trigger} {...tooltip.trigger}
> >

View file

@ -78,7 +78,7 @@
Be sure to login first. Be sure to login first.
{/if} {/if}
</p> </p>
<div class="mt-4 flex items-center gap-1"> <div class="mt-4 flex flex-wrap items-center gap-1">
{#each Object.entries(suggestionCategories) as [category, opts] (category)} {#each Object.entries(suggestionCategories) as [category, opts] (category)}
<button <button
type="button" type="button"
@ -105,7 +105,7 @@
<Button <Button
onclick={() => (prompt.current = suggestion)} onclick={() => (prompt.current = suggestion)}
variant="ghost" variant="ghost"
class="w-full cursor-pointer justify-start py-2 text-start" class="w-full cursor-pointer justify-start px-2 py-2 text-start"
> >
{suggestion} {suggestion}
</Button> </Button>
@ -117,7 +117,7 @@
<Button <Button
onclick={() => (prompt.current = suggestion)} onclick={() => (prompt.current = suggestion)}
variant="ghost" variant="ghost"
class="w-full cursor-pointer justify-start py-2 text-start group-last:line-through" class="w-full cursor-pointer justify-start px-2 py-2 text-start group-last:line-through"
> >
{suggestion} {suggestion}
</Button> </Button>

View file

@ -28,6 +28,8 @@
import { models as modelsState } from '$lib/state/models.svelte'; import { models as modelsState } from '$lib/state/models.svelte';
import { Provider } from '$lib/types'; import { Provider } from '$lib/types';
import Tooltip from '$lib/components/ui/tooltip.svelte'; import Tooltip from '$lib/components/ui/tooltip.svelte';
import fuzzysearch from '$lib/utils/fuzzy-search';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
type Props = { type Props = {
class?: string; class?: string;
@ -109,11 +111,21 @@
return 'other'; return 'other';
} }
let search = $state('');
const filteredModels = $derived(
fuzzysearch({
haystack: enabledArr,
needle: search,
property: 'model_id',
})
);
// Group models by company // Group models by company
const groupedModels = $derived.by(() => { const groupedModels = $derived.by(() => {
const groups: Record<string, typeof enabledArr> = {}; const groups: Record<string, typeof filteredModels> = {};
enabledArr.forEach((model) => { filteredModels.forEach((model) => {
const company = getCompanyFromModelId(model.model_id); const company = getCompanyFromModelId(model.model_id);
if (!groups[company]) { if (!groups[company]) {
groups[company] = []; groups[company] = [];
@ -184,6 +196,8 @@
secondary: formattedParts.slice(1).join(' '), secondary: formattedParts.slice(1).join(' '),
}; };
} }
const isMobile = new IsMobile();
</script> </script>
{#if enabledArr.length === 0} {#if enabledArr.length === 0}
@ -216,12 +230,19 @@
{...popover.content} {...popover.content}
class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex" class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex"
> >
<Command.Root class="flex h-full flex-col overflow-hidden" columns={4}> <Command.Root
<label class="group/label relative flex items-center gap-2 px-4 py-3 text-sm"> shouldFilter={false}
class="flex h-full flex-col overflow-hidden md:w-[572px]"
columns={isMobile.current ? undefined : 4}
>
<label
class="group/label border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm"
>
<SearchIcon class="text-muted-foreground" /> <SearchIcon class="text-muted-foreground" />
<Command.Input <Command.Input
class="w-full outline-none" class="w-full outline-none"
placeholder="Search models..." placeholder="Search models..."
bind:value={search}
{@attach (node) => { {@attach (node) => {
if (popover.open) { if (popover.open) {
node.focus(); node.focus();
@ -231,27 +252,63 @@
}; };
}} }}
/> />
<div
class="border-border/50 group-focus-within/label:border-foreground/30 absolute inset-x-2 bottom-0 h-1 border-b"
aria-hidden="true"
></div>
</label> </label>
<Command.List class="overflow-y-auto"> <Command.List class="h-[300px] overflow-y-auto md:h-[430px]">
<Command.Viewport> <Command.Viewport>
<Command.Empty class="text-muted-foreground p-4 text-sm"> <Command.Empty
class="text-muted-foreground flex items-center justify-center p-4 text-sm md:h-[120px]"
>
No models available. Enable some models in the account settings. No models available. Enable some models in the account settings.
</Command.Empty> </Command.Empty>
{#each groupedModels as [company, models] (company)} {#each groupedModels as [company, models] (company)}
<Command.Group class="space-y-2"> <Command.Group class="space-y-2">
<Command.GroupHeading <Command.GroupHeading
class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize" class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize md:scroll-m-[180px]"
> >
{company} {company}
</Command.GroupHeading> </Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-4 gap-3 px-3 pb-3"> <Command.GroupItems
class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3"
>
{#each models as model (model._id)} {#each models as model (model._id)}
{@const isSelected = settings.modelId === model.model_id} {@const isSelected = settings.modelId === model.model_id}
{@const formatted = formatModelName(model.model_id)} {@const formatted = formatModelName(model.model_id)}
{#if isMobile.current}
<Command.Item
value={model.model_id}
onSelect={() => selectModel(model.model_id)}
class={cn(
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
'relative scroll-m-2 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
isSelected && 'border-reflect border-none'
)}
>
<div class="flex items-center gap-2">
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p class="font-fake-proxima text-center leading-tight font-bold">
{formatted.full}
</p>
</div>
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div class="" {...tooltip.trigger}>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</Command.Item>
{:else}
<Command.Item <Command.Item
value={model.model_id} value={model.model_id}
onSelect={() => selectModel(model.model_id)} onSelect={() => selectModel(model.model_id)}
@ -259,8 +316,7 @@
'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2', 'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2',
'relative select-none', 'relative select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground', 'data-selected:bg-accent/50 data-selected:text-accent-foreground',
isSelected && 'border-reflect border-none', isSelected && 'border-reflect border-none'
'scroll-m-10'
)} )}
> >
{#if getModelIcon(model.model_id)} {#if getModelIcon(model.model_id)}
@ -291,6 +347,7 @@
</Tooltip> </Tooltip>
{/if} {/if}
</Command.Item> </Command.Item>
{/if}
{/each} {/each}
</Command.GroupItems> </Command.GroupItems>
</Command.Group> </Command.Group>