model picker wip
This commit is contained in:
parent
6881b5289d
commit
b32eb50c78
4 changed files with 254 additions and 5 deletions
|
|
@ -54,12 +54,14 @@ TODO: add instructions
|
|||
- [x] conversation title generation
|
||||
- [ ] kbd powered popover model picker
|
||||
- [x] autosize
|
||||
- [x] AbortController for message generation
|
||||
|
||||
### Extra
|
||||
|
||||
- [ ] Web Search
|
||||
- [ ] MCPs
|
||||
- [ ] Chat branching
|
||||
- [ ] Regenerate message
|
||||
- [ ] Image generation
|
||||
- [ ] Chat sharing
|
||||
- [ ] 404 page/redirect
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"@fontsource-variable/nunito-sans": "^5.2.6",
|
||||
"@fontsource-variable/open-sans": "^5.2.6",
|
||||
"better-auth": "^1.2.9",
|
||||
"bits-ui": "^2.8.0",
|
||||
"convex-helpers": "^0.1.94",
|
||||
"markdown-it-async": "^2.2.0",
|
||||
"openai": "^5.3.0",
|
||||
|
|
|
|||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
|
|
@ -35,6 +35,9 @@ importers:
|
|||
better-auth:
|
||||
specifier: ^1.2.9
|
||||
version: 1.2.9
|
||||
bits-ui:
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1)
|
||||
convex-helpers:
|
||||
specifier: ^0.1.94
|
||||
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
|
||||
|
|
@ -662,6 +665,9 @@ packages:
|
|||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -906,6 +912,9 @@ packages:
|
|||
svelte: ^5.0.0
|
||||
vite: ^6.0.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||
|
||||
|
|
@ -1223,6 +1232,13 @@ packages:
|
|||
better-call@1.0.9:
|
||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||
|
||||
bits-ui@2.8.0:
|
||||
resolution: {integrity: sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA==}
|
||||
engines: {node: '>=20', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
'@internationalized/date': ^3.8.1
|
||||
svelte: ^5.33.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
|
|
@ -2381,6 +2397,12 @@ packages:
|
|||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
svelte-toolbelt@0.9.2:
|
||||
resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
|
||||
svelte@5.34.1:
|
||||
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2388,6 +2410,9 @@ packages:
|
|||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
tailwind-merge@3.0.2:
|
||||
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||
|
||||
|
|
@ -3061,6 +3086,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
|
@ -3306,6 +3335,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -3651,6 +3684,18 @@ snapshots:
|
|||
set-cookie-parser: 2.7.1
|
||||
uncrypto: 0.1.3
|
||||
|
||||
bits-ui@2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.1
|
||||
'@floating-ui/dom': 1.7.1
|
||||
'@internationalized/date': 3.8.2
|
||||
css.escape: 1.5.1
|
||||
esm-env: 1.2.2
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
svelte: 5.34.1
|
||||
svelte-toolbelt: 0.9.2(svelte@5.34.1)
|
||||
tabbable: 6.2.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
|
@ -4744,6 +4789,13 @@ snapshots:
|
|||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte-toolbelt@0.9.2(svelte@5.34.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte@5.34.1:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -4763,6 +4815,8 @@ snapshots:
|
|||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tailwind-merge@3.0.2: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,23 @@
|
|||
import { session } from '$lib/state/session.svelte';
|
||||
import { settings } from '$lib/state/settings.svelte';
|
||||
import { cn } from '$lib/utils/utils';
|
||||
import { Command, Popover } from 'bits-ui';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||
import CheckIcon from '~icons/lucide/check';
|
||||
|
||||
// Company icons from simple-icons
|
||||
import OpenaiIcon from '~icons/simple-icons/openai';
|
||||
import GoogleIcon from '~icons/simple-icons/google';
|
||||
import MetaIcon from '~icons/simple-icons/meta';
|
||||
import MicrosoftIcon from '~icons/simple-icons/microsoft';
|
||||
import XIcon from '~icons/simple-icons/x';
|
||||
|
||||
// Fallback to lucide icons for companies without simple-icons
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import ZapIcon from '~icons/lucide/zap';
|
||||
import CpuIcon from '~icons/lucide/cpu';
|
||||
import RobotIcon from '~icons/lucide/bot';
|
||||
|
||||
type Props = {
|
||||
class?: string;
|
||||
|
|
@ -17,15 +34,190 @@
|
|||
|
||||
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||
|
||||
// Company icon mapping
|
||||
const companyIcons: Record<string, typeof OpenaiIcon> = {
|
||||
openai: OpenaiIcon,
|
||||
anthropic: BrainIcon,
|
||||
google: GoogleIcon,
|
||||
meta: MetaIcon,
|
||||
mistral: ZapIcon,
|
||||
'x-ai': XIcon,
|
||||
microsoft: MicrosoftIcon,
|
||||
qwen: CpuIcon,
|
||||
deepseek: RobotIcon,
|
||||
cohere: CpuIcon,
|
||||
};
|
||||
|
||||
// Function to extract company from model ID
|
||||
function getCompanyFromModelId(modelId: string): string {
|
||||
const id = modelId.toLowerCase();
|
||||
|
||||
// OpenAI models
|
||||
if (id.includes('gpt') || id.includes('o1') || id.includes('openai')) return 'openai';
|
||||
|
||||
// Anthropic models
|
||||
if (id.includes('claude') || id.includes('anthropic')) return 'anthropic';
|
||||
|
||||
// Google models
|
||||
if (
|
||||
id.includes('gemini') ||
|
||||
id.includes('gemma') ||
|
||||
id.includes('google') ||
|
||||
id.includes('palm')
|
||||
)
|
||||
return 'google';
|
||||
|
||||
// Meta models
|
||||
if (id.includes('llama') || id.includes('meta')) return 'meta';
|
||||
|
||||
// Mistral models
|
||||
if (id.includes('mistral') || id.includes('mixtral')) return 'mistral';
|
||||
|
||||
// xAI models
|
||||
if (id.includes('grok') || id.includes('x-ai')) return 'x-ai';
|
||||
|
||||
// Microsoft models
|
||||
if (id.includes('phi') || id.includes('microsoft')) return 'microsoft';
|
||||
|
||||
// Qwen models
|
||||
if (id.includes('qwen') || id.includes('alibaba')) return 'qwen';
|
||||
|
||||
// DeepSeek models
|
||||
if (id.includes('deepseek')) return 'deepseek';
|
||||
|
||||
// Cohere models
|
||||
if (id.includes('command') || id.includes('cohere')) return 'cohere';
|
||||
|
||||
// Try to extract from model path (e.g., "anthropic/claude-3")
|
||||
const pathParts = modelId.split('/');
|
||||
if (pathParts.length > 1) {
|
||||
const provider = pathParts[0]?.toLowerCase();
|
||||
if (provider && companyIcons[provider]) return provider;
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Group models by company
|
||||
const groupedModels = $derived.by(() => {
|
||||
console.log('📊 enabledArr:', enabledArr);
|
||||
const groups: Record<string, typeof enabledArr> = {};
|
||||
|
||||
enabledArr.forEach((model) => {
|
||||
const company = getCompanyFromModelId(model.model_id);
|
||||
console.log(`🏢 Model ${model.model_id} -> Company: ${company}`);
|
||||
if (!groups[company]) {
|
||||
groups[company] = [];
|
||||
}
|
||||
groups[company].push(model);
|
||||
});
|
||||
|
||||
console.log('📋 Groups:', groups);
|
||||
|
||||
// Sort companies with known icons first
|
||||
const result = Object.entries(groups).sort(([a], [b]) => {
|
||||
const aHasIcon = companyIcons[a] ? 0 : 1;
|
||||
const bHasIcon = companyIcons[b] ? 0 : 1;
|
||||
return aHasIcon - bHasIcon || a.localeCompare(b);
|
||||
});
|
||||
|
||||
console.log('🎯 Final grouped models:', result);
|
||||
return result;
|
||||
});
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Find current model details
|
||||
const currentModel = $derived(enabledArr.find((m) => m.model_id === settings.modelId));
|
||||
const currentCompany = $derived(
|
||||
currentModel ? getCompanyFromModelId(currentModel.model_id) : 'other'
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
|
||||
settings.modelId = enabledArr[0]!.model_id;
|
||||
}
|
||||
});
|
||||
|
||||
function selectModel(modelId: string) {
|
||||
settings.modelId = modelId;
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<select bind:value={settings.modelId} class={cn('border-border border', className)}>
|
||||
{#each enabledArr as model (model._id)}
|
||||
<option value={model.model_id}>{model.model_id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if enabledArr.length === 0}
|
||||
<!-- Fallback to original select if no models are loaded -->
|
||||
<select bind:value={settings.modelId} class={cn('border-border border', className)}>
|
||||
<option value="">Loading models...</option>
|
||||
</select>
|
||||
{:else}
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class={cn('flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', className)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if companyIcons[currentCompany]}
|
||||
{@const IconComponent = companyIcons[currentCompany]}
|
||||
<IconComponent class="size-4" />
|
||||
{/if}
|
||||
<span class="truncate">
|
||||
{currentModel?.model_id.replace(/^[^/]+\//, '') || 'Select model'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
class="z-50 mt-1 max-h-96 w-full min-w-80 overflow-hidden rounded-md border bg-white/95 p-0 shadow-lg backdrop-blur-sm dark:bg-gray-950/95"
|
||||
>
|
||||
<Command.Root columns={3}>
|
||||
<Command.Input
|
||||
class="flex h-10 w-full rounded-md border-0 border-b bg-transparent px-3 py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
<Command.List>
|
||||
<Command.Viewport>
|
||||
<Command.Empty>
|
||||
No models available. Enable some models in the account settings.
|
||||
</Command.Empty>
|
||||
{#each groupedModels as [company, models]}
|
||||
<Command.Group class="space-y-2">
|
||||
<Command.GroupHeading
|
||||
class="text-muted-foreground flex items-center gap-2 border-b border-gray-200 pb-1 pt-3 px-3 text-xs font-semibold tracking-wide uppercase dark:border-gray-700"
|
||||
>
|
||||
{#if companyIcons[company]}
|
||||
{@const IconComponent = companyIcons[company]}
|
||||
<IconComponent class="size-4" />
|
||||
{/if}
|
||||
{company} ({models.length})
|
||||
</Command.GroupHeading>
|
||||
<Command.GroupItems class="grid grid-cols-3 gap-2 px-3 pb-3">
|
||||
{#each models as model (model._id)}
|
||||
<Command.Item
|
||||
value={model.model_id}
|
||||
onSelect={() => selectModel(model.model_id)}
|
||||
class={cn(
|
||||
'data-selected:bg-accent data-selected:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-sm outline-none transition-colors',
|
||||
settings.modelId === model.model_id && 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-between gap-2">
|
||||
<span class="truncate text-left font-medium">
|
||||
{model.model_id.replace(/^[^/]+\//, '')}
|
||||
</span>
|
||||
{#if settings.modelId === model.model_id}
|
||||
<CheckIcon class="size-4 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.GroupItems>
|
||||
</Command.Group>
|
||||
{/each}
|
||||
</Command.Viewport>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue