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} +

+ + +
+ +
+ {#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} + {#if showReasoning} +
+ {message.reasoning} +
+ {/if} + + {/if}
{#if message.error}
@@ -146,6 +194,58 @@ {/if}
+ {#if annotations} +
+ + {annotations.length} + {annotations.length === 1 ? 'Citation' : 'Citations'} + +
+ {#each annotations as annotation} + {#if annotation.type === 'url_citation'} + {@const url = new URL(annotation.url_citation.url)} + + {@render siteIcon({ url })} + + {/if} + {/each} +
+
+
+ {#each annotations as annotation} + {#if annotation.type === 'url_citation'} + {@const url = new URL(annotation.url_citation.url)} +
+
+ + + {annotation.url_citation.title} + +

+ {annotation.url_citation.content} +

+
+ + {@render siteIcon({ url })} + {url.hostname} + + + +
+ {/if} + {/each} +
+ {/if}
{message.model_id} {/if} + {#if message.reasoning_effort} + + + {casing.camelToPascal(message.reasoning_effort)} + + {/if} {#if message.web_search_enabled} - Web search enabled + + + {/if} {#if message.cost_usd !== undefined} @@ -209,3 +317,14 @@ /> {/if} {/if} + +{#snippet siteIcon({ url }: { url: URL })} + + {#snippet children(avatar)} + {`${url.hostname} + + + + {/snippet} + +{/snippet} diff --git a/src/routes/chat/model-picker.svelte b/src/routes/chat/model-picker.svelte deleted file mode 100644 index ef44d06..0000000 --- a/src/routes/chat/model-picker.svelte +++ /dev/null @@ -1,345 +0,0 @@ - - -{#if enabledArr.length} - - -
-
- -
- {#each groupedModels as [company, models] (company)} -
-

- {company} -

-
- {#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)} - -
-
- {#if getModelIcon(model.model_id)} - {@const ModelIcon = getModelIcon(model.model_id)} - - {/if} - -

- {isMobile.current ? formatted.full : formatted.primary} -

- - {#if !isMobile.current} -

- {formatted.secondary} -

- {/if} -
- - {#if openRouterModel && supportsImages(openRouterModel)} - - {#snippet trigger(tooltip)} -
- -
- {/snippet} - Supports image anaylsis -
- {/if} -
- {/each} -
-
- {/each} -
-
-
-{/if}