From 7b9595e5718f841cb5394e4d0e9608dc75deb7bc Mon Sep 17 00:00:00 2001
From: Aidan Bleser <117548273+ieedan@users.noreply.github.com>
Date: Thu, 10 Jul 2025 06:45:02 -0500
Subject: [PATCH] Post Hackathon Stuff (#40)
Co-authored-by: Thomas G. Lopes <26071571+TGlide@users.noreply.github.com>
---
README.md | 1 -
package.json | 6 +-
pnpm-lock.yaml | 100 +++-
src/app.css | 2 +
src/lib/backend/convex/messages.ts | 12 +-
src/lib/backend/convex/schema.ts | 11 +-
src/lib/backend/convex/user_enabled_models.ts | 30 +-
src/lib/backend/convex/user_keys.ts | 22 +-
.../components/animations/shiny-text.svelte | 2 +-
src/lib/components/model-picker/index.ts | 3 +
.../model-picker/model-picker.svelte | 545 ++++++++++++++++++
.../dropdown-menu-checkbox-item.svelte | 41 ++
.../dropdown-menu-content.svelte | 27 +
.../dropdown-menu-group-heading.svelte | 22 +
.../dropdown-menu/dropdown-menu-group.svelte | 7 +
.../dropdown-menu/dropdown-menu-item.svelte | 27 +
.../dropdown-menu/dropdown-menu-label.svelte | 24 +
.../dropdown-menu-radio-group.svelte | 16 +
.../dropdown-menu-radio-item.svelte | 31 +
.../dropdown-menu-separator.svelte | 17 +
.../dropdown-menu-shortcut.svelte | 20 +
.../dropdown-menu-sub-content.svelte | 20 +
.../dropdown-menu-sub-trigger.svelte | 29 +
.../dropdown-menu-trigger.svelte | 7 +
src/lib/components/ui/dropdown-menu/index.ts | 49 ++
src/lib/components/ui/kbd/kbd.svelte | 1 +
src/lib/components/ui/popover/index.ts | 16 +
.../ui/popover/popover-content.svelte | 28 +
.../ui/popover/popover-trigger.svelte | 16 +
src/lib/spells/persisted-obj.svelte.ts | 21 +-
src/lib/state/settings.svelte.ts | 1 +
src/lib/types.ts | 20 +
src/lib/utils/casing.ts | 333 +++++++++++
src/lib/utils/is-letter.ts | 25 +
src/lib/utils/model-capabilities.ts | 4 +
src/routes/account/+layout.svelte | 8 +
src/routes/account/models/+page.svelte | 42 +-
src/routes/account/models/model-card.svelte | 47 +-
src/routes/api/generate-message/+server.ts | 22 +-
src/routes/chat/+layout.svelte | 42 +-
src/routes/chat/[id]/+page.svelte | 33 +-
src/routes/chat/[id]/message.svelte | 123 +++-
src/routes/chat/model-picker.svelte | 345 -----------
43 files changed, 1798 insertions(+), 400 deletions(-)
create mode 100644 src/lib/components/model-picker/index.ts
create mode 100644 src/lib/components/model-picker/model-picker.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
create mode 100644 src/lib/components/ui/dropdown-menu/index.ts
create mode 100644 src/lib/components/ui/popover/index.ts
create mode 100644 src/lib/components/ui/popover/popover-content.svelte
create mode 100644 src/lib/components/ui/popover/popover-trigger.svelte
create mode 100644 src/lib/utils/casing.ts
create mode 100644 src/lib/utils/is-letter.ts
delete mode 100644 src/routes/chat/model-picker.svelte
diff --git a/README.md b/README.md
index 03222a7..48d8d73 100644
--- a/README.md
+++ b/README.md
@@ -172,4 +172,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
💡 Request Feature
-
diff --git a/package.json b/package.json
index 12a176b..7c9e83e 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e00d949..fe3f148 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/app.css b/src/app.css
index 5f5f1ca..4e47f90 100644
--- a/src/app.css
+++ b/src/app.css
@@ -4,6 +4,8 @@
@import '@fontsource-variable/nunito-sans';
@import '@fontsource/instrument-serif';
+@import 'tw-animate-css';
+
@custom-variant dark (&:is(.dark *));
:root {
diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts
index f6dc7a4..adcfc3c 100644
--- a/src/lib/backend/convex/messages.ts
+++ b/src/lib/backend/convex/messages.ts
@@ -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,
});
},
});
diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts
index 97aef42..8db117b 100644
--- a/src/lib/backend/convex/schema.ts
+++ b/src/lib/backend/convex/schema.ts
@@ -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;
@@ -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']),
});
diff --git a/src/lib/backend/convex/user_enabled_models.ts b/src/lib/backend/convex/user_enabled_models.ts
index bdb784e..173f026 100644
--- a/src/lib/backend/convex/user_enabled_models.ts
+++ b/src/lib/backend/convex/user_enabled_models.ts
@@ -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,
})
)
);
diff --git a/src/lib/backend/convex/user_keys.ts b/src/lib/backend/convex/user_keys.ts
index a91a1c9..9848b07 100644
--- a/src/lib/backend/convex/user_keys.ts
+++ b/src/lib/backend/convex/user_keys.ts
@@ -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,
+ });
+ })
);
}
}
diff --git a/src/lib/components/animations/shiny-text.svelte b/src/lib/components/animations/shiny-text.svelte
index 5dcd114..cb0a75f 100644
--- a/src/lib/components/animations/shiny-text.svelte
+++ b/src/lib/components/animations/shiny-text.svelte
@@ -16,7 +16,7 @@
+ 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 = {
+ 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 = {};
+
+ 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)));
+
+
+ (open = true),
+ }}
+/>
+
+
+ {#if enabledArr.length}
+
+
+ {#if currentModel && getModelIcon(currentModel.model_id)}
+ {@const IconComponent = getModelIcon(currentModel.model_id)}
+
+ {/if}
+
+ {currentModel ? formatModelName(currentModel.model_id).full : 'Select model'}
+
+
+
+
+
+
+
+
+
+ {#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)}
+
+ modelSelected(model.model_id)}
+ >
+
+ {#if getModelIcon(model.model_id)}
+ {@const ModelIcon = getModelIcon(model.model_id)}
+
+ {/if}
+
+
+ {formatted.full}
+
+
+
+
+ {#if openRouterModel && supportsImages(openRouterModel)}
+
+ {#snippet trigger(tooltip)}
+
+
+
+ {/snippet}
+ Supports image analysis
+
+ {/if}
+
+ {#if openRouterModel && supportsReasoning(openRouterModel)}
+
+ {#snippet trigger(tooltip)}
+
+
+
+ {/snippet}
+ Supports reasoning
+
+ {/if}
+
+
+ {/each}
+ {:else if view === 'enabled'}
+ {#if pinnedModels.length > 0}
+
+
+ Pinned
+
+
+ {#each pinnedModels as model (model._id)}
+ {@render modelCard(model)}
+ {/each}
+
+
+ {/if}
+ {#each groupedModels as [company, models] (company)}
+ {@const filteredModels = models.filter((m) => !isPinned(m))}
+ {#if filteredModels.length > 0}
+
+
+ {company}
+
+
+ {#each filteredModels as model (model._id)}
+ {@render modelCard(model)}
+ {/each}
+
+
+ {/if}
+ {/each}
+ {/if}
+
+
+
+
+ {#if !isMobile.current && activeModelInfo && view === 'enabled'}
+
+
+
+ {/if}
+
+
+ {/if}
+
+
+{#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)}
+
+ modelSelected(model.model_id)}
+ >
+
+ {#if getModelIcon(model.model_id)}
+ {@const ModelIcon = getModelIcon(model.model_id)}
+
+ {/if}
+
+
+ {isMobile.current ? formatted.full : formatted.primary}
+
+
+
+ {formatted.secondary}
+
+
+
+
+ {#if openRouterModel && supportsImages(openRouterModel)}
+
+ {#snippet trigger(tooltip)}
+
+
+
+ {/snippet}
+ Supports image analysis
+
+ {/if}
+
+ {#if openRouterModel && supportsReasoning(openRouterModel)}
+
+ {#snippet trigger(tooltip)}
+
+
+
+ {/snippet}
+ Supports reasoning
+
+ {/if}
+
+
+
+
+
+
+{/snippet}
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
new file mode 100644
index 0000000..9ed6956
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#snippet children({ checked, indeterminate })}
+
+ {#if indeterminate}
+
+ {:else}
+
+ {/if}
+
+ {@render childrenProp?.()}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
new file mode 100644
index 0000000..8990bfe
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
new file mode 100644
index 0000000..ccc483c
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
new file mode 100644
index 0000000..261ab7e
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
new file mode 100644
index 0000000..ba5c946
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
new file mode 100644
index 0000000..05c91fb
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte
@@ -0,0 +1,24 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
new file mode 100644
index 0000000..3e98749
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
new file mode 100644
index 0000000..bd2c448
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#snippet children({ checked })}
+
+ {#if checked}
+
+ {/if}
+
+ {@render childrenProp?.({ checked })}
+ {/snippet}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
new file mode 100644
index 0000000..a8ac630
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte
@@ -0,0 +1,17 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
new file mode 100644
index 0000000..7ce000b
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte
@@ -0,0 +1,20 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
new file mode 100644
index 0000000..69b490f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
new file mode 100644
index 0000000..0bef89f
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte
@@ -0,0 +1,29 @@
+
+
+
+ {@render children?.()}
+
+
diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
new file mode 100644
index 0000000..032b645
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts
new file mode 100644
index 0000000..e7f0a79
--- /dev/null
+++ b/src/lib/components/ui/dropdown-menu/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/kbd/kbd.svelte b/src/lib/components/ui/kbd/kbd.svelte
index 14fa701..64e193b 100644
--- a/src/lib/components/ui/kbd/kbd.svelte
+++ b/src/lib/components/ui/kbd/kbd.svelte
@@ -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',
diff --git a/src/lib/components/ui/popover/index.ts b/src/lib/components/ui/popover/index.ts
new file mode 100644
index 0000000..c95cf83
--- /dev/null
+++ b/src/lib/components/ui/popover/index.ts
@@ -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,
+};
diff --git a/src/lib/components/ui/popover/popover-content.svelte b/src/lib/components/ui/popover/popover-content.svelte
new file mode 100644
index 0000000..c47788b
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-content.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/popover/popover-trigger.svelte b/src/lib/components/ui/popover/popover-trigger.svelte
new file mode 100644
index 0000000..4700cb6
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-trigger.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/src/lib/spells/persisted-obj.svelte.ts b/src/lib/spells/persisted-obj.svelte.ts
index 510ef76..232faa0 100644
--- a/src/lib/spells/persisted-obj.svelte.ts
+++ b/src/lib/spells/persisted-obj.svelte.ts
@@ -26,7 +26,7 @@ type PersistedObjOptions = {
syncTabs?: boolean;
};
-export function createPersistedObj(
+export function createPersistedObj>(
key: string,
initialValue: T,
options: PersistedObjOptions = {}
@@ -37,7 +37,7 @@ export function createPersistedObj(
syncTabs = true,
} = options;
- let current = initialValue;
+ let current: Record = initialValue;
let storage: Storage | undefined;
let subscribe: VoidFunction | undefined;
let version = $state(0);
@@ -47,7 +47,18 @@ export function createPersistedObj(
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(
version += 1;
}
- function deserialize(value: string): T | undefined {
+ function deserialize(value: string): Record | undefined {
try {
return serializer.deserialize(value);
} catch (error) {
@@ -75,7 +86,7 @@ export function createPersistedObj(
}
}
- function serialize(value: T | undefined): void {
+ function serialize(value: Record | undefined): void {
try {
if (value != undefined) {
storage?.setItem(key, serializer.serialize(value));
diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts
index 5e3d33e..0f286b5 100644
--- a/src/lib/state/settings.svelte.ts
+++ b/src/lib/state/settings.svelte.ts
@@ -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',
});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index e4dde8f..05ab240 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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;
+
+// if there are more types do this
+// export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
+export const AnnotationSchema = UrlCitationSchema;
+export type Annotation = z.infer;
diff --git a/src/lib/utils/casing.ts b/src/lib/utils/casing.ts
new file mode 100644
index 0000000..816d81c
--- /dev/null
+++ b/src/lib/utils/casing.ts
@@ -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;
+}
diff --git a/src/lib/utils/is-letter.ts b/src/lib/utils/is-letter.ts
new file mode 100644
index 0000000..c84cf51
--- /dev/null
+++ b/src/lib/utils/is-letter.ts
@@ -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);
+}
diff --git a/src/lib/utils/model-capabilities.ts b/src/lib/utils/model-capabilities.ts
index c3f4252..9f39a8b 100644
--- a/src/lib/utils/model-capabilities.ts
+++ b/src/lib/utils/model-capabilities.ts
@@ -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);
}
diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte
index 2cb0b4e..b2bc56d 100644
--- a/src/routes/account/+layout.svelte
+++ b/src/routes/account/+layout.svelte
@@ -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() {
diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte
index 638f175..23dfd52 100644
--- a/src/routes/account/models/+page.svelte
+++ b/src/routes/account/models/+page.svelte
@@ -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([]);
@@ -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 @@
+
+
diff --git a/src/routes/account/models/model-card.svelte b/src/routes/account/models/model-card.svelte
index a76d7f4..2ad59ef 100644
--- a/src/routes/account/models/model-card.svelte
+++ b/src/routes/account/models/model-card.svelte
@@ -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 @@
enabled, toggleEnabled} {disabled} />
- {showMore ? fullDescription : (shortDescription ?? fullDescription)}
+
+ {showMore ? fullDescription : (shortDescription ?? fullDescription)}
+
{#if shortDescription !== null}