Post Hackathon Stuff (#40)

Co-authored-by: Thomas G. Lopes <26071571+TGlide@users.noreply.github.com>
This commit is contained in:
Aidan Bleser 2025-07-10 06:45:02 -05:00 committed by GitHub
parent a96ba2152b
commit 7b9595e571
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1798 additions and 400 deletions

View file

@ -172,4 +172,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
<a href="https://github.com/yourusername/thom-chat/issues">💡 Request Feature</a>
</p>
</div>

View file

@ -33,6 +33,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4",
"@vercel/functions": "^2.2.0",
"bits-ui": "^2.8.5",
"clsx": "^2.1.1",
"concurrently": "^9.1.2",
"convex": "^1.24.8",
@ -44,9 +45,10 @@
"globals": "^16.0.0",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^26.0.0",
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
"melt": "^0.38.0",
"mode-watcher": "^1.0.8",
"neverthrow": "^8.2.0",
"openai": "^5.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@ -58,6 +60,7 @@
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"unplugin-icons": "^22.1.0",
@ -84,7 +87,6 @@
"convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",
"markdown-it-async": "^2.2.0",
"openai": "^5.3.0",
"zod": "^3.25.64"
}
}

100
pnpm-lock.yaml generated
View file

@ -47,9 +47,6 @@ importers:
markdown-it-async:
specifier: ^2.2.0
version: 2.2.0
openai:
specifier: ^5.3.0
version: 5.3.0(ws@8.18.2)(zod@3.25.64)
zod:
specifier: ^3.25.64
version: 3.25.64
@ -99,6 +96,9 @@ importers:
'@vercel/functions':
specifier: ^2.2.0
version: 2.2.0
bits-ui:
specifier: ^2.8.5
version: 2.8.5(@internationalized/date@3.8.2)(svelte@5.34.1)
clsx:
specifier: ^2.1.1
version: 2.1.1
@ -133,14 +133,17 @@ importers:
specifier: ^26.0.0
version: 26.1.0
melt:
specifier: https://pkg.vc/-/@melt-ui/melt@42e572f
version: https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1)
specifier: ^0.38.0
version: 0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1)
mode-watcher:
specifier: ^1.0.8
version: 1.0.8(svelte@5.34.1)
neverthrow:
specifier: ^8.2.0
version: 8.2.0
openai:
specifier: ^5.5.1
version: 5.5.1(ws@8.18.2)(zod@3.25.64)
prettier:
specifier: ^3.4.2
version: 3.5.3
@ -174,6 +177,9 @@ importers:
tailwindcss:
specifier: ^4.0.0
version: 4.1.10
tw-animate-css:
specifier: ^1.3.4
version: 1.3.4
typescript:
specifier: ^5.0.0
version: 5.8.3
@ -677,6 +683,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'}
@ -921,6 +930,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==}
@ -1241,6 +1253,13 @@ packages:
better-call@1.0.9:
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
bits-ui@2.8.5:
resolution: {integrity: sha512-GVVDcmc+mziNNWdzlBviN3HjFAIdEFddQFvTA5cjronMan8PnIhpNhc2+DKL5CYdTbrz6kuyt2YvuvnoWYmovw==}
engines: {node: '>=20'}
peerDependencies:
'@internationalized/date': ^3.8.1
svelte: ^5.33.0
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@ -1602,6 +1621,9 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
focus-trap@7.6.5:
resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1919,9 +1941,8 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
melt@https://pkg.vc/-/@melt-ui/melt@42e572f:
resolution: {tarball: https://pkg.vc/-/@melt-ui/melt@42e572f}
version: 0.35.0
melt@0.38.0:
resolution: {integrity: sha512-fVUZdZSYUeYw4uwZkY+KoxmbVVMY14qEA21wjIR0ELAMlWc/eSeG0JcZn0wSB7dB1JdWo7KsVrZssieiQWSp5A==}
peerDependencies:
'@floating-ui/dom': ^1.6.0
svelte: ^5.30.1
@ -1997,11 +2018,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
nanostores@0.11.4:
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -2022,8 +2038,8 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
openai@5.3.0:
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
openai@5.5.1:
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
hasBin: true
peerDependencies:
ws: ^8.18.0
@ -2420,6 +2436,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'}
@ -2427,6 +2449,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==}
@ -2514,6 +2539,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.3.4:
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -3102,6 +3130,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
@ -3347,6 +3379,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
@ -3695,6 +3731,17 @@ snapshots:
set-cookie-parser: 2.7.1
uncrypto: 0.1.3
bits-ui@2.8.5(@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
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
@ -4072,6 +4119,10 @@ snapshots:
flatted@3.3.3: {}
focus-trap@7.6.5:
dependencies:
tabbable: 6.2.0
fsevents@2.3.2:
optional: true
@ -4389,12 +4440,12 @@ snapshots:
mdurl@2.0.0: {}
melt@https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1):
melt@0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1):
dependencies:
'@floating-ui/dom': 1.7.1
dequal: 2.0.3
focus-trap: 7.6.5
jest-axe: 9.0.0
nanoid: 5.1.5
runed: 0.23.4(svelte@5.34.1)
svelte: 5.34.1
@ -4461,8 +4512,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.5: {}
nanostores@0.11.4: {}
natural-compare@1.4.0: {}
@ -4481,7 +4530,7 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
openai@5.5.1(ws@8.18.2)(zod@3.25.64):
optionalDependencies:
ws: 8.18.2
zod: 3.25.64
@ -4821,6 +4870,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
@ -4840,6 +4896,8 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.2.0: {}
tailwind-merge@3.0.2: {}
tailwind-merge@3.3.1: {}
@ -4909,6 +4967,8 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.3.4: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View file

@ -4,6 +4,8 @@
@import '@fontsource-variable/nunito-sans';
@import '@fontsource/instrument-serif';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {

View file

@ -2,7 +2,7 @@ import { v } from 'convex/values';
import { api } from './_generated/api';
import { type Id } from './_generated/dataModel';
import { query } from './_generated/server';
import { messageRoleValidator, providerValidator } from './schema';
import { messageRoleValidator, providerValidator, reasoningEffortValidator } from './schema';
import { mutation } from './functions';
export const getAllFromConversation = query({
@ -47,6 +47,7 @@ export const create = mutation({
provider: v.optional(providerValidator),
token_count: v.optional(v.number()),
web_search_enabled: v.optional(v.boolean()),
reasoning_effort: v.optional(reasoningEffortValidator),
// Optional image attachments
images: v.optional(
v.array(
@ -94,6 +95,7 @@ export const create = mutation({
provider: args.provider,
token_count: args.token_count,
web_search_enabled: args.web_search_enabled,
reasoning_effort: args.reasoning_effort,
// Optional image attachments
images: args.images,
}),
@ -112,7 +114,11 @@ export const updateContent = mutation({
session_token: v.string(),
message_id: v.string(),
content: v.string(),
reasoning: v.optional(v.string()),
content_html: v.optional(v.string()),
generation_id: v.optional(v.string()),
reasoning_effort: v.optional(reasoningEffortValidator),
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
@ -131,7 +137,11 @@ export const updateContent = mutation({
await ctx.db.patch(message._id, {
content: args.content,
reasoning: args.reasoning,
content_html: args.content_html,
generation_id: args.generation_id,
annotations: args.annotations,
reasoning_effort: args.reasoning_effort,
});
},
});

View file

@ -8,6 +8,11 @@ export const messageRoleValidator = v.union(
v.literal('assistant'),
v.literal('system')
);
export const reasoningEffortValidator = v.union(
v.literal('low'),
v.literal('medium'),
v.literal('high')
);
export type MessageRole = Infer<typeof messageRoleValidator>;
@ -31,7 +36,8 @@ export default defineSchema({
provider: providerValidator,
/** Different providers may use different ids for the same model */
model_id: v.string(),
pinned: v.union(v.number(), v.null()),
// null is just here for compat we treat null as true
pinned: v.optional(v.union(v.boolean(), v.null())),
})
.index('by_user', ['user_id'])
.index('by_model_provider', ['model_id', 'provider'])
@ -61,6 +67,7 @@ export default defineSchema({
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(),
content_html: v.optional(v.string()),
reasoning: v.optional(v.string()),
error: v.optional(v.string()),
// Optional, coming from SK API route
model_id: v.optional(v.string()),
@ -79,5 +86,7 @@ export default defineSchema({
cost_usd: v.optional(v.number()),
generation_id: v.optional(v.string()),
web_search_enabled: v.optional(v.boolean()),
reasoning_effort: v.optional(reasoningEffortValidator),
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
}).index('by_conversation', ['conversation_id']),
});

View file

@ -109,12 +109,38 @@ export const set = mutation({
await ctx.db.insert('user_enabled_models', {
...object.pick(args, ['provider', 'model_id']),
user_id: session.userId,
pinned: null,
pinned: false,
});
}
},
});
export const toggle_pinned = mutation({
args: {
session_token: v.string(),
enabled_model_id: v.id('user_enabled_models'),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(internal.betterAuth.getSession, {
sessionToken: args.session_token,
});
if (!session) throw new Error('Invalid session token');
const model = await ctx.db.get(args.enabled_model_id);
if (!model) throw new Error('Model not found');
await ctx.db.patch(args.enabled_model_id, {
pinned: !isPinned(model),
});
},
});
export function isPinned(model: Doc<'user_enabled_models'>) {
return model.pinned === null || model.pinned;
}
export const enable_initial = mutation({
args: {
session_token: v.string(),
@ -150,7 +176,7 @@ export const enable_initial = mutation({
user_id: session.userId,
provider: Provider.OpenRouter,
model_id: model,
pinned: null,
pinned: true,
})
)
);

View file

@ -99,14 +99,26 @@ export const set = mutation({
];
await Promise.all(
defaultModels.map((model) =>
ctx.db.insert('user_enabled_models', {
defaultModels.map(async (model) => {
const existing = await ctx.db
.query('user_enabled_models')
.withIndex('by_model_provider_user', (q) =>
q
.eq('model_id', model)
.eq('provider', Provider.OpenRouter)
.eq('user_id', session.userId)
)
.first();
if (existing) return;
await ctx.db.insert('user_enabled_models', {
user_id: session.userId,
provider: Provider.OpenRouter,
model_id: model,
pinned: null,
pinned: true,
});
})
)
);
}
}

View file

@ -16,7 +16,7 @@
<p
style:--shimmer-width="{shimmerWidth}px"
class={cn(
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
'max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
// Shimmer effect
'animate-shimmer [background-size:var(--shimmer-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',

View file

@ -0,0 +1,3 @@
import ModelPicker from './model-picker.svelte';
export { ModelPicker };

View file

@ -0,0 +1,545 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import Cohere from '$lib/components/icons/cohere.svelte';
import Deepseek from '$lib/components/icons/deepseek.svelte';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { models as modelsState } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
import { Provider } from '$lib/types';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import { capitalize } from '$lib/utils/strings';
import { cn } from '$lib/utils/utils';
import { type Component } from 'svelte';
import LogosClaudeIcon from '~icons/logos/claude-icon';
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
import BrainIcon from '~icons/lucide/brain';
import ChevronDownIcon from '~icons/lucide/chevron-down';
import CpuIcon from '~icons/lucide/cpu';
import EyeIcon from '~icons/lucide/eye';
import SearchIcon from '~icons/lucide/search';
import ZapIcon from '~icons/lucide/zap';
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
import GoogleIcon from '~icons/simple-icons/google';
import MetaIcon from '~icons/simple-icons/meta';
import MicrosoftIcon from '~icons/simple-icons/microsoft';
import OpenaiIcon from '~icons/simple-icons/openai';
import XIcon from '~icons/simple-icons/x';
import { Command } from 'bits-ui';
import * as Popover from '$lib/components/ui/popover';
import { shortcut } from '$lib/actions/shortcut.svelte';
import { Button } from '../ui/button';
import ChevronLeftIcon from '~icons/lucide/chevron-left';
import { Kbd } from '../ui/kbd';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte';
import { useConvexClient } from 'convex-svelte';
import type { Id } from '$lib/backend/convex/_generated/dataModel';
import { ResultAsync } from 'neverthrow';
import PinIcon from '~icons/lucide/pin';
import PinOffIcon from '~icons/lucide/pin-off';
import { isPinned } from '$lib/backend/convex/user_enabled_models';
type Props = {
class?: string;
/* When images are attached, we should not select models that don't support images */
onlyImageModels?: boolean;
};
let { class: className, onlyImageModels }: Props = $props();
const client = useConvexClient();
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
session_token: session.current?.session.token ?? '',
});
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
modelsState.init();
// Company icon mapping
const companyIcons: Record<string, Component> = {
openai: OpenaiIcon,
anthropic: LogosClaudeIcon,
google: GoogleIcon,
meta: MetaIcon,
mistral: ZapIcon,
'x-ai': XIcon,
microsoft: MicrosoftIcon,
qwen: CpuIcon,
deepseek: Deepseek,
cohere: Cohere,
};
function getModelIcon(modelId: string): Component | null {
const id = modelId.toLowerCase();
// Model-specific icons take priority
if (id.includes('claude') || id.includes('anthropic')) return LogosClaudeIcon;
if (id.includes('gemini') || id.includes('gemma')) return MaterialIconThemeGeminiAi;
if (id.includes('mistral') || id.includes('mixtral')) return LogosMistralAiIcon;
// Fallback to company icons
const company = getCompanyFromModelId(modelId);
return companyIcons[company] || null;
}
function getCompanyFromModelId(modelId: string): string {
const id = modelId.toLowerCase();
if (id.includes('gpt') || id.includes('o1') || id.includes('openai')) return 'openai';
if (id.includes('claude') || id.includes('anthropic')) return 'anthropic';
if (
id.includes('gemini') ||
id.includes('gemma') ||
id.includes('google') ||
id.includes('palm')
)
return 'google';
if (id.includes('llama') || id.includes('meta')) return 'meta';
if (id.includes('mistral') || id.includes('mixtral')) return 'mistral';
if (id.includes('grok') || id.includes('x-ai')) return 'x-ai';
if (id.includes('phi') || id.includes('microsoft')) return 'microsoft';
if (id.includes('qwen') || id.includes('alibaba')) return 'qwen';
if (id.includes('deepseek')) return 'deepseek';
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';
}
let search = $state('');
const filteredModels = $derived(
fuzzysearch({
haystack: enabledArr,
needle: search,
property: 'model_id',
})
);
// Group models by company
const groupedModels = $derived.by(() => {
const groups: Record<string, typeof filteredModels> = {};
filteredModels.forEach((model) => {
const company = getCompanyFromModelId(model.model_id);
if (!groups[company]) {
groups[company] = [];
}
groups[company].push(model);
});
// 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);
});
return result;
});
const currentModel = $derived(enabledArr.find((m) => m.model_id === settings.modelId));
$effect(() => {
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.model_id;
}
});
let open = $state(false);
let view = $state<'favorites' | 'enabled'>('favorites');
let activeModel = $state('');
// Model name formatting utility
const termReplacements = [
{ from: 'gpt', to: 'GPT' },
{ from: 'claude', to: 'Claude' },
{ from: 'deepseek', to: 'DeepSeek' },
{ from: 'o3', to: 'o3' },
];
function formatModelName(modelId: string) {
const cleanId = modelId.replace(/^[^/]+\//, '');
const parts = cleanId.split(/[-_,:]/);
const formattedParts = parts.map((part) => {
let formatted = capitalize(part);
termReplacements.forEach(({ from, to }) => {
formatted = formatted.replace(new RegExp(`\\b${from}\\b`, 'gi'), to);
});
return formatted;
});
return {
full: formattedParts.join(' '),
primary: formattedParts[0] || '',
secondary: formattedParts.slice(1).join(' '),
};
}
function modelSelected(modelId: string) {
settings.modelId = modelId;
open = false;
}
function toggleView() {
view = view === 'favorites' ? 'enabled' : 'favorites';
}
let pinning = $state(false);
async function togglePin(modelId: Id<'user_enabled_models'>) {
pinning = true;
await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.toggle_pinned, {
session_token: session.current?.session.token ?? '',
enabled_model_id: modelId,
}),
(e) => e
);
pinning = false;
}
const isMobile = new IsMobile();
const activeModelInfo = $derived.by(() => {
if (activeModel === '') return null;
const model = enabledArr.find((m) => m.model_id === activeModel);
if (!model) return null;
return {
...model,
formatted: formatModelName(activeModel),
};
});
const pinnedModels = $derived(enabledArr.filter((m) => isPinned(m)));
</script>
<svelte:window
use:shortcut={{
ctrl: true,
shift: true,
key: 'm',
callback: () => (open = true),
}}
/>
<Popover.Root bind:open>
{#if enabledArr.length}
<Popover.Trigger
class={cn(
'ring-offset-background focus:ring-ring flex items-center justify-between rounded-lg px-2 py-1 text-xs transition hover:text-white focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
<div class="flex items-center gap-2 pr-2">
{#if currentModel && getModelIcon(currentModel.model_id)}
{@const IconComponent = getModelIcon(currentModel.model_id)}
<IconComponent class="size-3" />
{/if}
<span class="truncate">
{currentModel ? formatModelName(currentModel.model_id).full : 'Select model'}
</span>
</div>
<ChevronDownIcon class="size-4 opacity-50" />
</Popover.Trigger>
<Popover.Content
portalProps={{
disabled: true
}}
align="start"
sideOffset={5}
class={cn('p-0 transition-all', {
'w-[572px]': !isMobile.current && view === 'enabled',
'w-[300px]': view === 'favorites',
'max-w-[calc(100vw-2rem)]': isMobile.current,
})}
>
<Command.Root
class={cn('flex h-full w-full flex-col overflow-hidden')}
bind:value={activeModel}
columns={view === 'favorites' ? undefined : isMobile.current ? 2 : 4}
>
<label class="border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm">
<SearchIcon class="text-muted-foreground" />
<Command.Input
class="w-full outline-none"
placeholder="Search models..."
onkeydown={(e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
view = 'enabled';
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
view = 'favorites';
} else if (e.key === 'u') {
if (activeModelInfo) {
e.preventDefault();
e.stopPropagation();
togglePin(activeModelInfo._id);
}
}
}
}}
/>
</label>
<Command.List
class={cn('overflow-y-auto transition-all', {
'h-[430px]': view === 'enabled',
'flex flex-col gap-1 p-1': view === 'favorites',
})}
style="height: {view === 'enabled'
? '430px'
: `min(300px, ${pinnedModels.length * 44 + 4}px)`};"
>
{#if view === 'favorites' && pinnedModels.length > 0}
{#each pinnedModels as model (model._id)}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{@const disabled =
onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
<Command.Item
value={model.model_id}
class={cn(
'bg-popover flex rounded-lg p-2',
'relative scroll-m-36 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
'h-10 items-center justify-between',
disabled && 'opacity-50'
)}
onSelect={() => modelSelected(model.model_id)}
>
<div class={cn('flex items-center gap-2')}>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-4 shrink-0" />
{/if}
<p class={cn('font-fake-proxima text-center text-sm leading-tight font-bold')}>
{formatted.full}
</p>
</div>
<div class="flex place-items-center gap-1">
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
</Tooltip>
{/if}
{#if openRouterModel && supportsReasoning(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
</div>
</Command.Item>
{/each}
{:else if view === 'enabled'}
{#if pinnedModels.length > 0}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
>
Pinned
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each pinnedModels as model (model._id)}
{@render modelCard(model)}
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{#each groupedModels as [company, models] (company)}
{@const filteredModels = models.filter((m) => !isPinned(m))}
{#if filteredModels.length > 0}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
>
{company}
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each filteredModels as model (model._id)}
{@render modelCard(model)}
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{/each}
{/if}
</Command.List>
</Command.Root>
<div class="border-border flex place-items-center justify-between border-t p-2">
<Button variant="ghost" size="sm" onclick={toggleView} class="h-7 text-sm font-normal">
<ChevronLeftIcon
class={cn('size-4 rotate-90 transition-all', { 'rotate-0': view === 'enabled' })}
/>
{view === 'favorites' ? 'Show enabled' : 'Show favorites'}
{#if !isMobile.current}
<span>
<Kbd size="xs">{cmdOrCtrl}</Kbd>
<Kbd size="xs">{view === 'favorites' ? '→' : '←'}</Kbd>
</span>
{/if}
</Button>
{#if !isMobile.current && activeModelInfo && view === 'enabled'}
<div>
<Button
variant="ghost"
loading={pinning}
class="bg-popover"
size="sm"
onclick={() => togglePin(activeModelInfo._id)}
>
<span class="text-muted-foreground">
{isPinned(activeModelInfo) ? 'Unpin' : 'Pin'}
</span>
<span>
<Kbd size="xs">{cmdOrCtrl}</Kbd>
<Kbd size="xs">U</Kbd>
</span>
</Button>
</div>
{/if}
</div>
</Popover.Content>
{/if}
</Popover.Root>
{#snippet modelCard(model: (typeof enabledArr)[number])}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{@const disabled = onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
<Command.Item
value={model.model_id}
class={cn(
'border-border bg-popover group/item flex gap-2 rounded-lg border p-2',
'relative scroll-m-36 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
'h-36 w-32 flex-col items-center justify-center',
disabled && 'opacity-50'
)}
onSelect={() => modelSelected(model.model_id)}
>
<div class={cn('flex flex-col items-center')}>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p
class={cn(
'font-fake-proxima mt-2 text-center text-sm leading-tight font-medium md:mt-0 md:text-base md:font-bold'
)}
>
{isMobile.current ? formatted.full : formatted.primary}
</p>
<p class="mt-0 hidden text-center text-xs leading-tight font-medium md:block">
{formatted.secondary}
</p>
</div>
<div class="flex place-items-center gap-1">
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
</Tooltip>
{/if}
{#if openRouterModel && supportsReasoning(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
</div>
<div
class="bg-popover absolute top-1 right-1 scale-75 rounded-md p-1 md:opacity-0 transition-all group-hover/item:scale-100 group-hover/item:opacity-100"
>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={(e: MouseEvent) => {
e.stopPropagation();
togglePin(model._id);
}}
>
{#if isPinned(model)}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</Button>
</div>
</Command.Item>
{/snippet}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '~icons/lucide/check';
import MinusIcon from '~icons/lucide/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/utils.js';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
'bg-popover border-border text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '~icons/lucide/circle';
import { cn, type WithoutChild } from '$lib/utils/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...restProps}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '~icons/lucide/chevron-right';
import { cn } from '$lib/utils/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />

View file

@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import RadioGroup from './dropdown-menu-radio-group.svelte';
import RadioItem from './dropdown-menu-radio-item.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};

View file

@ -14,6 +14,7 @@
primary: 'bg-primary text-primary-foreground',
},
size: {
xs: 'min-w-5 gap-1.5 p-0.5 px-0.5 text-xs',
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
default: 'min-w-8 gap-1.5 p-1 px-2',
lg: 'min-w-9 gap-2 p-1 px-3 text-lg',

View file

@ -0,0 +1,16 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
import Trigger from './popover-trigger.svelte';
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover border-border text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>

View file

@ -26,7 +26,7 @@ type PersistedObjOptions<T> = {
syncTabs?: boolean;
};
export function createPersistedObj<T extends object>(
export function createPersistedObj<T extends Record<string, unknown>>(
key: string,
initialValue: T,
options: PersistedObjOptions<T> = {}
@ -37,7 +37,7 @@ export function createPersistedObj<T extends object>(
syncTabs = true,
} = options;
let current = initialValue;
let current: Record<string, unknown> = initialValue;
let storage: Storage | undefined;
let subscribe: VoidFunction | undefined;
let version = $state(0);
@ -47,7 +47,18 @@ export function createPersistedObj<T extends object>(
const existingValue = storage.getItem(key);
if (existingValue !== null) {
const deserialized = deserialize(existingValue);
if (deserialized) current = deserialized;
if (deserialized) {
// handle keys that were added at a later point in time
for (const key of Object.keys(initialValue)) {
const initialKeyValue = deserialized[key];
if (initialKeyValue === undefined) {
deserialized[key] = initialValue[key];
}
}
current = deserialized;
}
} else {
serialize(initialValue);
}
@ -66,7 +77,7 @@ export function createPersistedObj<T extends object>(
version += 1;
}
function deserialize(value: string): T | undefined {
function deserialize(value: string): Record<string, unknown> | undefined {
try {
return serializer.deserialize(value);
} catch (error) {
@ -75,7 +86,7 @@ export function createPersistedObj<T extends object>(
}
}
function serialize(value: T | undefined): void {
function serialize(value: Record<string, unknown> | undefined): void {
try {
if (value != undefined) {
storage?.setItem(key, serializer.serialize(value));

View file

@ -3,4 +3,5 @@ import { createPersistedObj } from '$lib/spells/persisted-obj.svelte';
export const settings = createPersistedObj('settings', {
modelId: undefined as string | undefined,
webSearchEnabled: false,
reasoningEffort: 'low' as 'low' | 'medium' | 'high',
});

View file

@ -1,3 +1,5 @@
import { z } from 'zod';
export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
@ -14,3 +16,21 @@ export type ProviderMeta = {
models?: string[];
placeholder?: string;
};
export const UrlCitationSchema = z.object({
type: z.literal('url_citation'),
url_citation: z.object({
end_index: z.number(),
start_index: z.number(),
title: z.string(),
url: z.string(),
content: z.string(),
}),
});
export type UrlCitation = z.infer<typeof UrlCitationSchema>;
// if there are more types do this
// export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
export const AnnotationSchema = UrlCitationSchema;
export type Annotation = z.infer<typeof AnnotationSchema>;

333
src/lib/utils/casing.ts Normal file
View file

@ -0,0 +1,333 @@
/*
Installed from @ieedan/std
*/
import { isLetter } from '$lib/utils/is-letter';
/** Converts a `camelCase` string to a `snake_case` string
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToSnake('helloWorld'); // hello_world
* ```
*/
export function camelToSnake(str: string): string {
let newStr = '';
for (let i = 0; i < str.length; i++) {
// is uppercase letter
if (isLetter(str[i]) && str[i].toUpperCase() === str[i]) {
let l = i;
while (l < str.length && isLetter(str[l]) && str[l].toUpperCase() === str[l]) {
l++;
}
newStr += `${str.slice(i, l - 1).toLocaleLowerCase()}_${str[l - 1].toLocaleLowerCase()}`;
i = l - 1;
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `PascalCase` string to a `snake_case` string
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToSnake('HelloWorld'); // hello_world
* ```
*/
export function pascalToSnake(str: string): string {
let newStr = '';
let firstLetter: number | undefined;
for (let i = 0; i < str.length; i++) {
if (firstLetter === undefined && isLetter(str[i])) {
firstLetter = i;
}
// is uppercase letter (ignoring the first)
if (
firstLetter !== undefined &&
i > firstLetter &&
isLetter(str[i]) &&
str[i].toUpperCase() === str[i]
) {
let l = i;
while (l < str.length && isLetter(str[l]) && str[l].toUpperCase() === str[l]) {
l++;
}
newStr += `${str.slice(i, l - 1).toLocaleLowerCase()}_${str[l - 1].toLocaleLowerCase()}`;
i = l - 1;
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `camelCase` string to a `kebab-case` string
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToSnake('helloWorld'); // hello-world
* ```
*/
export function camelToKebab(str: string): string {
let newStr = '';
for (let i = 0; i < str.length; i++) {
// is uppercase letter
if (i > 0 && isLetter(str[i]) && str[i].toUpperCase() === str[i]) {
let l = i;
while (l < str.length && isLetter(str[l]) && str[l].toUpperCase() === str[l]) {
l++;
}
newStr += `${str.slice(i, l - 1).toLocaleLowerCase()}-${str[l - 1].toLocaleLowerCase()}`;
i = l - 1;
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `PascalCase` string to a `kebab-case` string
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToSnake('HelloWorld'); // hello-world
* ```
*/
export function pascalToKebab(str: string): string {
let newStr = '';
for (let i = 0; i < str.length; i++) {
// is uppercase letter (ignoring the first)
if (i > 0 && isLetter(str[i]) && str[i].toUpperCase() === str[i]) {
let l = i;
while (l < str.length && isLetter(str[l]) && str[l].toUpperCase() === str[l]) {
l++;
}
newStr += `${str.slice(i, l - 1).toLocaleLowerCase()}-${str[l - 1].toLocaleLowerCase()}`;
i = l - 1;
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `camelCase` string to a `PascalCase` string (makes first letter lowercase)
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToPascal('helloWorld'); // HelloWorld
* ```
*/
export function camelToPascal(str: string): string {
return `${str[0].toLocaleUpperCase()}${str.slice(1)}`;
}
/** Converts a `PascalCase` string to a `camelCase` string (makes first letter uppercase)
*
* @param str
* @returns
*
* ## Usage
* ```ts
* camelToPascal('HelloWorld'); // helloWorld
* ```
*/
export function pascalToCamel(str: string): string {
return `${str[0].toLocaleLowerCase()}${str.slice(1)}`;
}
/** Converts a `snake_case` string to a `PascalCase` string
*
*
* @param str
* @returns
*
* ## Usage
* ```ts
* snakeToPascal('hello_world'); // HelloWorld
* snakeToPascal('HELLO_WORLD'); // HelloWorld
* ```
*/
export function snakeToPascal(str: string): string {
let newStr = '';
let firstLetter = true;
for (let i = 0; i < str.length; i++) {
// capitalize first letter
if (firstLetter && isLetter(str[i])) {
firstLetter = false;
newStr += str[i].toUpperCase();
continue;
}
// capitalize first after a _ (ignoring the first)
if (!firstLetter && str[i] === '_') {
i++;
if (i <= str.length - 1) {
newStr += str[i].toUpperCase();
} else {
newStr += '_';
}
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `snake_case` string to a `camelCase` string
*
*
* @param str
* @returns
*
* ## Usage
* ```ts
* snakeToCamel('hello_world'); // helloWorld
* snakeToCamel('HELLO_WORLD'); // helloWorld
* ```
*/
export function snakeToCamel(str: string): string {
let newStr = '';
let firstLetter = true;
for (let i = 0; i < str.length; i++) {
// capitalize first letter
if (firstLetter && isLetter(str[i])) {
firstLetter = false;
newStr += str[i].toLowerCase();
continue;
}
// capitalize first after a _ (ignoring the first)
if (!firstLetter && str[i] === '_') {
i++;
if (i <= str.length - 1) {
newStr += str[i].toUpperCase();
} else {
newStr += '_';
}
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `kebab-case` string to a `PascalCase` string
*
* @param str
* @returns
*
* ## Usage
* ```ts
* kebabToPascal('hello-world'); // HelloWorld
* ```
*/
export function kebabToPascal(str: string): string {
let newStr = '';
for (let i = 0; i < str.length; i++) {
// capitalize first
if (i === 0) {
newStr += str[i].toUpperCase();
continue;
}
// capitalize first after a -
if (str[i] === '-') {
i++;
if (i <= str.length - 1) {
newStr += str[i].toUpperCase();
}
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}
/** Converts a `kebab-case` string to a `camelCase` string
*
*
* @param str
* @returns
*
* ## Usage
* ```ts
* kebabToCamel('hello-world'); // helloWorld
* ```
*/
export function kebabToCamel(str: string): string {
let newStr = '';
for (let i = 0; i < str.length; i++) {
// capitalize first after a -
if (str[i] === '-') {
i++;
if (i <= str.length - 1) {
newStr += str[i].toUpperCase();
}
continue;
}
newStr += str[i].toLocaleLowerCase();
}
return newStr;
}

View file

@ -0,0 +1,25 @@
/*
Installed from @ieedan/std
*/
export const LETTER_REGEX = new RegExp(/[a-zA-Z]/);
/** Checks if the provided character is a letter in the alphabet.
*
* @param char
* @returns
*
* ## Usage
* ```ts
* isLetter('a');
* ```
*/
export function isLetter(char: string): boolean {
if (char.length > 1) {
throw new Error(
`You probably only meant to pass a character to this function. Instead you gave ${char}`
);
}
return LETTER_REGEX.test(char);
}

View file

@ -4,6 +4,10 @@ export function supportsImages(model: OpenRouterModel): boolean {
return model.architecture.input_modalities.includes('image');
}
export function supportsReasoning(model: OpenRouterModel): boolean {
return model.supported_parameters.includes('reasoning');
}
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
return models.filter(supportsImages);
}

View file

@ -56,6 +56,14 @@
name: 'Search Messages',
keys: [cmdOrCtrl, 'K'],
},
{
name: 'Scroll to bottom',
keys: [cmdOrCtrl, 'D'],
},
{
name: 'Open Model Picker',
keys: [cmdOrCtrl, 'Shift', 'M'],
},
];
async function signOut() {

View file

@ -12,6 +12,7 @@
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,
@ -32,7 +33,14 @@
const freeModelsToggle = new Toggle({
value: false,
disabled: false,
});
const reasoningModelsToggle = new Toggle({
value: false,
});
const imageModelsToggle = new Toggle({
value: false,
});
let initiallyEnabled = $state<string[]>([]);
@ -48,11 +56,19 @@
const openRouterModels = $derived(
fuzzysearch({
haystack: models.from(Provider.OpenRouter).filter((m) => {
if (!freeModelsToggle.value) return true;
if (freeModelsToggle.value) {
if (m.pricing.prompt !== '0') return false;
}
if (m.pricing.prompt === '0') return true;
if (reasoningModelsToggle.value) {
if (!supportsReasoning(m)) return false;
}
return false;
if (imageModelsToggle.value) {
if (!supportsImages(m)) return false;
}
return true;
}),
needle: search,
property: 'name',
@ -96,6 +112,24 @@
<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>

View file

@ -7,6 +7,11 @@
import { session } from '$lib/state/session.svelte.js';
import { ResultAsync } from 'neverthrow';
import { getFirstSentence } from '$lib/utils/strings';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import type { OpenRouterModel } from '$lib/backend/models/open-router';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import EyeIcon from '~icons/lucide/eye';
import BrainIcon from '~icons/lucide/brain';
type Model = {
id: string;
@ -15,10 +20,11 @@
};
type Props = {
provider: Provider;
model: Model;
enabled?: boolean;
disabled?: boolean;
} & {
provider: typeof Provider.OpenRouter;
model: OpenRouterModel;
};
let { provider, model, enabled = false, disabled = false }: Props = $props();
@ -56,9 +62,9 @@
</div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div>
<Card.Description
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
>
<Card.Description>
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
</Card.Description>
{#if shortDescription !== null}
<button
type="button"
@ -70,4 +76,35 @@
</button>
{/if}
</Card.Header>
<Card.Content>
<div class="flex place-items-center gap-1">
{#if model && provider === 'openrouter' && supportsImages(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image analysis
</Tooltip>
{/if}
{#if model && provider === 'openrouter' && supportsReasoning(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
</div>
</Card.Content>
</Card.Root>

View file

@ -2,7 +2,7 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { Provider } from '$lib/types';
import { Provider, type Annotation } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit';
import { waitUntil } from '@vercel/functions';
import { getSessionCookie } from 'better-auth/cookies';
@ -35,6 +35,7 @@ const reqBodySchema = z
})
)
.optional(),
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
})
.refine(
(data) => {
@ -184,6 +185,7 @@ async function generateAIResponse({
rulesResultPromise,
userSettingsPromise,
abortSignal,
reasoningEffort,
}: {
conversationId: string;
sessionToken: string;
@ -193,6 +195,7 @@ async function generateAIResponse({
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal;
reasoningEffort?: 'low' | 'medium' | 'high';
}) {
log('Starting AI response generation in background', startTime);
@ -466,6 +469,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
messages: messagesToSend,
temperature: 0.7,
stream: true,
reasoning_effort: reasoningEffort,
},
{
signal: abortSignal,
@ -489,8 +493,10 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
log('Background: OpenAI stream created successfully', startTime);
let content = '';
let reasoning = '';
let chunkCount = 0;
let generationId: string | null = null;
const annotations: Annotation[] = [];
try {
for await (const chunk of stream) {
@ -500,8 +506,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}
chunkCount++;
// @ts-expect-error you're wrong
reasoning += chunk.choices[0]?.delta?.reasoning || '';
content += chunk.choices[0]?.delta?.content || '';
if (!content) continue;
// @ts-expect-error you're wrong
annotations.push(...(chunk.choices[0]?.delta?.annotations ?? []));
if (!content && !reasoning) continue;
generationId = chunk.id;
@ -509,7 +521,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
);
@ -746,6 +762,7 @@ export const POST: RequestHandler = async ({ request }) => {
content: args.message,
session_token: args.session_token,
model_id: args.model_id,
reasoning_effort: args.reasoning_effort,
role: 'user',
images: args.images,
web_search_enabled: args.web_search_enabled,
@ -792,6 +809,7 @@ export const POST: RequestHandler = async ({ request }) => {
rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal,
reasoningEffort: args.reasoning_effort,
})
.catch(async (error) => {
log(`Background AI response generation error: ${error}`, startTime);

View file

@ -20,7 +20,7 @@
import { settings } from '$lib/state/settings.svelte.js';
import { Provider } from '$lib/types';
import { compressImage } from '$lib/utils/image-compression';
import { supportsImages } from '$lib/utils/model-capabilities';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import { omit, pick } from '$lib/utils/object.js';
import { cn } from '$lib/utils/utils.js';
import { useConvexClient } from 'convex-svelte';
@ -38,13 +38,16 @@
import XIcon from '~icons/lucide/x';
import { callCancelGeneration } from '../api/cancel-generation/call.js';
import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte';
import { ModelPicker } from '$lib/components/model-picker';
import SearchModal from './search-modal.svelte';
import { shortcut } from '$lib/actions/shortcut.svelte.js';
import { mergeAttrs } from 'melt';
import { callEnhancePrompt } from '../api/enhance-prompt/call.js';
import ShinyText from '$lib/components/animations/shiny-text.svelte';
import SparkleIcon from '~icons/lucide/sparkle';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import BrainIcon from '~icons/lucide/brain';
import * as casing from '$lib/utils/casing.js';
const client = useConvexClient();
@ -127,6 +130,7 @@
model_id: settings.modelId,
images: imagesCopy.length > 0 ? imagesCopy : undefined,
web_search_enabled: settings.webSearchEnabled,
reasoning_effort: currentModelSupportsReasoning ? settings.reasoningEffort : undefined,
});
if (res.isErr()) {
@ -214,6 +218,14 @@
return currentModel ? supportsImages(currentModel) : false;
});
const currentModelSupportsReasoning = $derived.by(() => {
if (!settings.modelId) return false;
const openRouterModels = models.from(Provider.OpenRouter);
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
if (!currentModel) return false;
return supportsReasoning(currentModel);
});
const fileUpload = new FileUpload({
multiple: true,
accept: 'image/*',
@ -697,6 +709,32 @@
<div class="flex flex-wrap items-center gap-2 pr-2">
<ModelPicker onlyImageModels={selectedImages.length > 0} />
<div class="flex items-center gap-2">
{#if currentModelSupportsReasoning}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
>
<BrainIcon class="!size-3" />
<span class="hidden whitespace-nowrap sm:inline">
{casing.camelToPascal(settings.reasoningEffort)}
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'high')}>
<BrainIcon class="size-4" />
High
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'medium')}>
<BrainIcon class="size-4" />
Medium
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'low')}>
<BrainIcon class="size-4" />
Low
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<button
type="button"
class={cn(

View file

@ -10,6 +10,9 @@
import { last } from '$lib/utils/array';
import { settings } from '$lib/state/settings.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import ShinyText from '$lib/components/animations/shiny-text.svelte';
import GlobeIcon from '~icons/lucide/globe';
import LoaderCircleIcon from '~icons/lucide/loader-circle';
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
conversation_id: page.params.id ?? '',
@ -21,6 +24,8 @@
session_token: session.current?.session.token ?? '',
}));
const lastMessage = $derived(messages?.data?.[messages.data?.length - 1] ?? null);
const lastMessageHasContent = $derived.by(() => {
if (!messages.data) return false;
const lastMessage = messages.data[messages.data.length - 1];
@ -32,6 +37,15 @@
return lastMessage.content.length > 0;
});
const lastMessageHasReasoning = $derived.by(() => {
if (!messages.data) return false;
const lastMessage = messages.data[messages.data.length - 1];
if (!lastMessage) return false;
return lastMessage.reasoning?.length ?? 0 > 0;
});
let changedRoute = $state(false);
watch(
() => page.params.id,
@ -74,8 +88,23 @@
{#each messages.data ?? [] as message (message._id)}
<Message {message} />
{/each}
{#if conversation.data?.generating && !lastMessageHasContent}
{#if conversation.data?.generating}
{#if lastMessage?.web_search_enabled}
{#if lastMessage?.annotations === undefined || lastMessage?.annotations?.length === 0}
<div class="flex place-items-center gap-2">
<GlobeIcon class="inline size-4 shrink-0" />
<ShinyText class="text-muted-foreground text-sm">Searching the web...</ShinyText>
</div>
{/if}
{:else if !lastMessageHasReasoning && !lastMessageHasContent}
<LoadingDots />
{:else}
<div class="flex place-items-center gap-2">
<div class="flex animate-[spin_0.65s_linear_infinite] place-items-center justify-center">
<LoaderCircleIcon class="size-4" />
</div>
</div>
{/if}
{/if}
{/if}
</div>

View file

@ -19,6 +19,14 @@
import { callGenerateMessage } from '../../api/generate-message/call';
import * as Icons from '$lib/components/icons';
import { settings } from '$lib/state/settings.svelte';
import ShinyText from '$lib/components/animations/shiny-text.svelte';
import ChevronRightIcon from '~icons/lucide/chevron-right';
import { AnnotationSchema, type Annotation } from '$lib/types';
import ExternalLinkIcon from '~icons/lucide/external-link';
import GlobeIcon from '~icons/lucide/globe';
import { Avatar } from 'melt/components';
import BrainIcon from '~icons/lucide/brain';
import * as casing from '$lib/utils/casing';
const style = tv({
base: 'prose rounded-xl p-2 max-w-full',
@ -86,9 +94,27 @@
await goto(`/chat/${cid}`);
}
const annotations = $derived.by(() => {
if (!message.annotations || message.annotations.length === 0) return null;
const annotations: Annotation[] = [];
for (const annotation of message.annotations) {
const parsed = AnnotationSchema.safeParse(annotation);
if (!parsed.success) continue;
annotations.push(parsed.data);
}
return annotations;
});
let showReasoning = $state(false);
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && message.reasoning?.length === 0 && !message.error)}
<div
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
{@attach (node) => {
@ -123,6 +149,28 @@
{/each}
</div>
{/if}
{#if message.reasoning}
<div class="my-8">
<button
type="button"
class="text-muted-foreground flex items-center gap-1 pb-2 text-sm"
aria-label="Toggle reasoning"
onclick={() => (showReasoning = !showReasoning)}
>
<ChevronRightIcon class={cn('inline size-4', { 'rotate-90': showReasoning })} />
{#if message.content.length === 0}
<ShinyText>Reasoning...</ShinyText>
{:else}
<span>Reasoning</span>
{/if}
</button>
{#if showReasoning}
<div class="text-muted-foreground/50 bg-popover relative rounded-lg p-2 text-xs">
{message.reasoning}
</div>
{/if}
</div>
{/if}
<div class={style({ role: message.role })}>
{#if message.error}
<div class="text-destructive">
@ -146,6 +194,58 @@
</svelte:boundary>
{/if}
</div>
{#if annotations}
<div class="flex items-center gap-2">
<span class="text-muted-foreground pl-2 text-xs">
{annotations.length}
{annotations.length === 1 ? 'Citation' : 'Citations'}
</span>
<div class="flex items-center">
{#each annotations as annotation}
{#if annotation.type === 'url_citation'}
{@const url = new URL(annotation.url_citation.url)}
<a
href={annotation.url_citation.url}
target="_blank"
class="border-border bg-background bg-noise -m-1 flex place-items-center justify-center rounded-full border p-0.5 transition-transform hover:scale-110"
>
{@render siteIcon({ url })}
</a>
{/if}
{/each}
</div>
</div>
<div class="scrollbar-hide flex place-items-center gap-2 overflow-x-auto p-2">
{#each annotations as annotation}
{#if annotation.type === 'url_citation'}
{@const url = new URL(annotation.url_citation.url)}
<div
class="border-border hover:border-primary/50 text-muted-foreground group relative flex h-32 min-w-60 flex-col justify-between rounded-lg border p-4 transition-colors"
>
<div>
<a
href={annotation.url_citation.url}
target="_blank"
class="group-hover:text-foreground block max-w-full truncate font-medium transition-colors"
>
<span class="absolute inset-0"></span>
{annotation.url_citation.title}
</a>
<p class="truncate text-sm">
{annotation.url_citation.content}
</p>
</div>
<span class="flex items-center gap-2 text-xs">
{@render siteIcon({ url })}
{url.hostname}
</span>
<ExternalLinkIcon class="text-primary absolute top-2 right-2 size-3" />
</div>
{/if}
{/each}
</div>
{/if}
<div
class={cn(
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
@ -188,8 +288,16 @@
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}
{#if message.reasoning_effort}
<span class="text-muted-foreground text-xs">
<BrainIcon class="inline-block size-4 shrink-0 text-green-500" />
{casing.camelToPascal(message.reasoning_effort)}
</span>
{/if}
{#if message.web_search_enabled}
<span class="text-muted-foreground text-xs"> Web search enabled </span>
<span class="text-muted-foreground text-xs">
<GlobeIcon class="text-primary inline-block size-4 shrink-0" />
</span>
{/if}
{#if message.cost_usd !== undefined}
@ -209,3 +317,14 @@
/>
{/if}
{/if}
{#snippet siteIcon({ url }: { url: URL })}
<Avatar src={`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`}>
{#snippet children(avatar)}
<img {...avatar.image} alt={`${url.hostname} site icon`} />
<span {...avatar.fallback}>
<GlobeIcon class="inline-block size-4 shrink-0" />
</span>
{/snippet}
</Avatar>
{/snippet}

View file

@ -1,345 +0,0 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { GridCommand } from '$lib/builders/grid-command.svelte';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import Cohere from '$lib/components/icons/cohere.svelte';
import Deepseek from '$lib/components/icons/deepseek.svelte';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { models as modelsState } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
import { Provider } from '$lib/types';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { supportsImages } from '$lib/utils/model-capabilities';
import { capitalize } from '$lib/utils/strings';
import { cn } from '$lib/utils/utils';
import { mergeAttrs } from 'melt';
import { Popover } from 'melt/builders';
import { tick, type Component } from 'svelte';
import { type HTMLAttributes } from 'svelte/elements';
import LogosClaudeIcon from '~icons/logos/claude-icon';
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
import BrainIcon from '~icons/lucide/brain';
import ChevronDownIcon from '~icons/lucide/chevron-down';
import CpuIcon from '~icons/lucide/cpu';
import EyeIcon from '~icons/lucide/eye';
import SearchIcon from '~icons/lucide/search';
import ZapIcon from '~icons/lucide/zap';
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
import GoogleIcon from '~icons/simple-icons/google';
import MetaIcon from '~icons/simple-icons/meta';
import MicrosoftIcon from '~icons/simple-icons/microsoft';
import OpenaiIcon from '~icons/simple-icons/openai';
import XIcon from '~icons/simple-icons/x';
type Props = {
class?: string;
/* When images are attached, we should not select models that don't support images */
onlyImageModels?: boolean;
};
let { class: className, onlyImageModels }: Props = $props();
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
session_token: session.current?.session.token ?? '',
});
const gridCommand = new GridCommand({
columns: () => (isMobile.current ? 1 : 4),
onSelect: (value) => {
settings.modelId = value;
popover.open = false;
gridCommand.inputValue = '';
},
});
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
modelsState.init();
// Company icon mapping
const companyIcons: Record<string, Component> = {
openai: OpenaiIcon,
anthropic: BrainIcon,
google: GoogleIcon,
meta: MetaIcon,
mistral: ZapIcon,
'x-ai': XIcon,
microsoft: MicrosoftIcon,
qwen: CpuIcon,
deepseek: Deepseek,
cohere: Cohere,
};
function getModelIcon(modelId: string): Component | null {
const id = modelId.toLowerCase();
// Model-specific icons take priority
if (id.includes('claude') || id.includes('anthropic')) return LogosClaudeIcon;
if (id.includes('gemini') || id.includes('gemma')) return MaterialIconThemeGeminiAi;
if (id.includes('mistral') || id.includes('mixtral')) return LogosMistralAiIcon;
// Fallback to company icons
const company = getCompanyFromModelId(modelId);
return companyIcons[company] || null;
}
function getCompanyFromModelId(modelId: string): string {
const id = modelId.toLowerCase();
if (id.includes('gpt') || id.includes('o1') || id.includes('openai')) return 'openai';
if (id.includes('claude') || id.includes('anthropic')) return 'anthropic';
if (
id.includes('gemini') ||
id.includes('gemma') ||
id.includes('google') ||
id.includes('palm')
)
return 'google';
if (id.includes('llama') || id.includes('meta')) return 'meta';
if (id.includes('mistral') || id.includes('mixtral')) return 'mistral';
if (id.includes('grok') || id.includes('x-ai')) return 'x-ai';
if (id.includes('phi') || id.includes('microsoft')) return 'microsoft';
if (id.includes('qwen') || id.includes('alibaba')) return 'qwen';
if (id.includes('deepseek')) return 'deepseek';
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';
}
const filteredModels = $derived(
fuzzysearch({
haystack: enabledArr,
needle: gridCommand.inputValue,
property: 'model_id',
})
);
// Group models by company
const groupedModels = $derived.by(() => {
const groups: Record<string, typeof filteredModels> = {};
filteredModels.forEach((model) => {
const company = getCompanyFromModelId(model.model_id);
if (!groups[company]) {
groups[company] = [];
}
groups[company].push(model);
});
// 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);
});
return result;
});
const currentModel = $derived(enabledArr.find((m) => m.model_id === settings.modelId));
$effect(() => {
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.model_id;
}
});
let open = $state(false);
const popover = new Popover({
open: () => open,
onOpenChange: (v) => {
if (v === open) return;
open = v;
if (v) {
tick().then(() => {
gridCommand.scrollToHighlighted();
});
}
document.getElementById(popover.trigger.id)?.focus();
},
floatingConfig: {
computePosition: { placement: 'top-start' },
},
});
// Model name formatting utility
const termReplacements = [
{ from: 'gpt', to: 'GPT' },
{ from: 'claude', to: 'Claude' },
{ from: 'deepseek', to: 'DeepSeek' },
{ from: 'o3', to: 'o3' },
];
function formatModelName(modelId: string) {
const cleanId = modelId.replace(/^[^/]+\//, '');
const parts = cleanId.split(/[-_,:]/);
const formattedParts = parts.map((part) => {
let formatted = capitalize(part);
termReplacements.forEach(({ from, to }) => {
formatted = formatted.replace(new RegExp(`\\b${from}\\b`, 'gi'), to);
});
return formatted;
});
return {
full: formattedParts.join(' '),
primary: formattedParts[0] || '',
secondary: formattedParts.slice(1).join(' '),
};
}
const isMobile = new IsMobile();
</script>
{#if enabledArr.length}
<button
{...popover.trigger}
class={cn(
'ring-offset-background focus:ring-ring flex items-center justify-between rounded-lg px-2 py-1 text-xs transition hover:text-white focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
aria-expanded={open}
>
<div class="flex items-center gap-2 pr-2">
{#if currentModel && getModelIcon(currentModel.model_id)}
{@const IconComponent = getModelIcon(currentModel.model_id)}
<IconComponent class="size-4" />
{/if}
<span class="truncate">
{currentModel ? formatModelName(currentModel.model_id).full : 'Select model'}
</span>
</div>
<ChevronDownIcon class="size-4 opacity-50" />
</button>
<div
{...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"
>
<div class="flex h-full flex-col overflow-hidden md:w-[572px]" {...gridCommand.root}>
<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" />
<input
class="w-full outline-none"
placeholder="Search models..."
{@attach (node) => {
if (popover.open) {
node.focus();
}
return () => {
node.value = '';
};
}}
{...mergeAttrs(gridCommand.input as unknown as HTMLAttributes<HTMLElement>, {
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Escape') {
popover.open = false;
gridCommand.inputValue = '';
}
},
})}
/>
</label>
<div class="h-[300px] overflow-y-auto md:h-[430px]">
{#each groupedModels as [company, models] (company)}
<div {...gridCommand.group} class="space-y-2">
<p
class="text-heading/75 flex scroll-m-2 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
{...gridCommand.groupHeading}
>
{company}
</p>
<div 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)}
{@const isSelected = settings.modelId === model.model_id}
{@const formatted = formatModelName(model.model_id)}
{@const openRouterModel = modelsState
.from(Provider.OpenRouter)
.find((m) => m.id === model.model_id)}
{@const disabled =
onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
<div
{...gridCommand.getItem(model.model_id, {
disabled,
})}
class={cn(
'border-border flex rounded-lg border p-2',
'relative scroll-m-2 select-none',
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
isSelected && 'border-reflect border-none',
isMobile.current
? 'h-10 items-center justify-between'
: 'h-40 w-32 flex-col items-center justify-center',
disabled && 'opacity-50'
)}
>
<div class={cn('flex items-center', isMobile.current ? 'gap-2' : 'flex-col')}>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p
class={cn(
'font-fake-proxima text-center leading-tight font-bold',
!isMobile.current && 'mt-2'
)}
>
{isMobile.current ? formatted.full : formatted.primary}
</p>
{#if !isMobile.current}
<p class="mt-0 text-center text-xs leading-tight font-medium">
{formatted.secondary}
</p>
{/if}
</div>
{#if openRouterModel && supportsImages(openRouterModel)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
class={cn(
isMobile.current
? ''
: 'abs-x-center text-muted-foreground absolute bottom-3 flex items-center gap-1 text-xs'
)}
{...tooltip.trigger}
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports image anaylsis
</Tooltip>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}