Compare commits

..

No commits in common. "c77f5fb8773015b5420ec118349bc19425850829" and "cbe926fe8c878f2cebb1d0279b381b10f0058acc" have entirely different histories.

72 changed files with 1175 additions and 6580 deletions

View file

@ -13,11 +13,4 @@ GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Optional: Development API keys for testing (not required for production)
# Users will provide their own API keys through the settings interface
DEV_OPENAI_API_KEY=
DEV_ANTHROPIC_API_KEY=
DEV_GEMINI_API_KEY=
DEV_MISTRAL_API_KEY=
DEV_COHERE_API_KEY=
DEV_OPENROUTER_API_KEY=
OPENROUTER_FREE_KEY=

View file

@ -1,42 +0,0 @@
name: '🐛 Bug report'
description: Report an issue with thom.chat
labels: ['bug']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: bug-description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is. Feel free to include screenshots or video recordings here as well! If you intend to submit a PR for this issue, tell us how in the description. Thanks!
placeholder: Bug description
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: 'Please include browser console and server logs around the time this bug occurred. Optional if provided reproduction. Please try not to insert an image but copy paste the log text.'
render: bash
- type: textarea
id: system-info
attributes:
label: System Info
description: Output of `npx envinfo --system --npmPackages svelte,@sveltejs/kit,@melt-ui/svelte --binaries --browsers`
render: bash
placeholder: System, Binaries, Browsers
validations:
required: true
- type: dropdown
id: severity
attributes:
label: Severity
description: Select the severity of this issue
options:
- annoyance
- blocking an upgrade
- blocking all usage of thom.chat
validations:
required: true

View file

@ -1,35 +0,0 @@
name: '💫 Feature Request'
description: Request a new feature
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to request this feature!
- type: textarea
id: problem
attributes:
label: Describe the problem
description: Please provide a clear and concise description the problem this feature would solve. The more information you can provide here, the better.
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the proposed solution
description: Please provide a clear and concise description of what you would like to happen.
placeholder: I would like to see...
validations:
required: true
- type: dropdown
id: importance
attributes:
label: Importance
description: How important is this feature to you?
options:
- Nice to have
- Would make my life easier
- I cannot use thom.chat without it
validations:
required: true

1
.gitignore vendored
View file

@ -25,4 +25,3 @@ vite.config.ts.timestamp-*
.aider*
src/lib/backend/convex/_generated
tmp/

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"svelte-llm": {
"type": "http",
"url": "https://svelte-llm.stanislav.garden/mcp/mcp"
}
}
}

View file

@ -32,10 +32,10 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
### 🤖 **AI & Models**
- **Multiple AI Providers** - OpenAI, Anthropic, Google Gemini, Mistral, Cohere, OpenRouter
- **600+ AI Models** across all providers
- **Bring Your Own API Keys** - Users must provide their own API keys
- **No Usage Limits** - Use any model without restrictions when you have the API key
- **400+ AI Models** via OpenRouter integration
- **Free Tier** with 10 messages using premium models
- **Unlimited Free Models** (models ending in `:free`)
- **Bring Your Own Key** for unlimited access
### 💬 **Chat Experience**
@ -79,7 +79,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- 🔧 Convex Database
- 🔐 BetterAuth
- 🤖 Kepler AI SDK (Multi-provider support)
- 🤖 OpenRouter API
- 🦾 Blood, sweat, and tears
</td>
@ -92,7 +92,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- Node.js 18+
- pnpm (recommended)
- At least one AI provider API key (OpenAI, Anthropic, Gemini, etc.)
- OpenRouter API key (optional for free tier)
### Installation
@ -129,28 +129,16 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
## 🎮 Usage
### Getting Started
### Free Tier
1. **Sign up** for a free account
2. **Add API Keys** - Go to Settings and add API keys for the providers you want to use:
- **OpenAI** - GPT models, DALL-E, Whisper
- **Anthropic** - Claude models
- **Google Gemini** - Gemini models and vision
- **Mistral** - Mistral models and embeddings
- **Cohere** - Command models and embeddings
- **OpenRouter** - Access to 300+ models
3. **Start Chatting** - Select any model from your enabled providers
- Sign up and get **10 free messages** with premium models
- Use **unlimited free models** (ending in `:free`)
- No credit card required
### Supported Providers
### Premium Features
| Provider | Models | Streaming | Tools | Vision | Embeddings |
|----------|---------|-----------|-------|--------|------------|
| OpenAI | GPT-4, o3-mini, DALL-E, TTS | ✅ | ✅ | ✅ | ✅ |
| Anthropic | Claude 4, Claude 3.5 Sonnet | ✅ | ✅ | ✅ | ❌ |
| Google Gemini | Gemini 2.5 Pro, Imagen | ✅ | ✅ | ✅ | ✅ |
| Mistral | Mistral Large, Mistral Embed | ✅ | ✅ | ❌ | ✅ |
| Cohere | Command A, Command R+ | ✅ | ✅ | ❌ | ✅ |
| OpenRouter | 300+ models | ✅ | ✅ | ✅ | ❌ |
- Add your own OpenRouter API key for unlimited access
- Access to all 400+ models
## 🤝 Contributing
@ -170,7 +158,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Inspired by [T3 Chat](https://t3.chat/)
- Built with [SvelteKit](https://kit.svelte.dev/)
- Powered by [Kepler AI SDK](https://deepwiki.com/keplersystems/kepler-ai-sdk)
- Powered by [OpenRouter](https://openrouter.ai/)
- Database by [Convex](https://convex.dev/)
---
@ -184,3 +172,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
<a href="https://github.com/yourusername/thom-chat/issues">💡 Request Feature</a>
</p>
</div>

1580
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,6 @@
"@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",
@ -45,14 +44,9 @@
"globals": "^16.0.0",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^26.0.0",
"melt": "^0.38.0",
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
"mode-watcher": "^1.0.8",
"neverthrow": "^8.2.0",
"@anthropic-ai/sdk": "^0.29.0",
"@google/generative-ai": "^0.21.0",
"@mistralai/mistralai": "^1.1.0",
"cohere-ai": "^7.14.0",
"openai": "^5.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@ -64,7 +58,6 @@
"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",
@ -87,11 +80,11 @@
"@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "file:./tmp/kepler-ai-sdk",
"better-auth": "^1.2.9",
"convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",
"markdown-it-async": "^2.2.0",
"openai": "^5.3.0",
"zod": "^3.25.64"
}
}

100
pnpm-lock.yaml generated
View file

@ -47,6 +47,9 @@ 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
@ -96,9 +99,6 @@ 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,17 +133,14 @@ importers:
specifier: ^26.0.0
version: 26.1.0
melt:
specifier: ^0.38.0
version: 0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1)
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)
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
@ -177,9 +174,6 @@ 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
@ -683,9 +677,6 @@ 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'}
@ -930,9 +921,6 @@ 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==}
@ -1253,13 +1241,6 @@ 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==}
@ -1621,9 +1602,6 @@ 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}
@ -1941,8 +1919,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
melt@0.38.0:
resolution: {integrity: sha512-fVUZdZSYUeYw4uwZkY+KoxmbVVMY14qEA21wjIR0ELAMlWc/eSeG0JcZn0wSB7dB1JdWo7KsVrZssieiQWSp5A==}
melt@https://pkg.vc/-/@melt-ui/melt@42e572f:
resolution: {tarball: https://pkg.vc/-/@melt-ui/melt@42e572f}
version: 0.35.0
peerDependencies:
'@floating-ui/dom': ^1.6.0
svelte: ^5.30.1
@ -2018,6 +1997,11 @@ 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}
@ -2038,8 +2022,8 @@ packages:
oniguruma-to-es@4.3.3:
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
openai@5.5.1:
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
openai@5.3.0:
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
hasBin: true
peerDependencies:
ws: ^8.18.0
@ -2436,12 +2420,6 @@ 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'}
@ -2449,9 +2427,6 @@ 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==}
@ -2539,9 +2514,6 @@ 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'}
@ -3130,10 +3102,6 @@ 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
@ -3379,10 +3347,6 @@ 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
@ -3731,17 +3695,6 @@ 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
@ -4119,10 +4072,6 @@ snapshots:
flatted@3.3.3: {}
focus-trap@7.6.5:
dependencies:
tabbable: 6.2.0
fsevents@2.3.2:
optional: true
@ -4440,12 +4389,12 @@ snapshots:
mdurl@2.0.0: {}
melt@0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1):
melt@https://pkg.vc/-/@melt-ui/melt@42e572f(@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
@ -4512,6 +4461,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.5: {}
nanostores@0.11.4: {}
natural-compare@1.4.0: {}
@ -4530,7 +4481,7 @@ snapshots:
regex: 6.0.1
regex-recursion: 6.0.2
openai@5.5.1(ws@8.18.2)(zod@3.25.64):
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
optionalDependencies:
ws: 8.18.2
zod: 3.25.64
@ -4870,13 +4821,6 @@ 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
@ -4896,8 +4840,6 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.2.0: {}
tailwind-merge@3.0.2: {}
tailwind-merge@3.3.1: {}
@ -4967,8 +4909,6 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.3.4: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View file

@ -4,8 +4,6 @@
@import '@fontsource-variable/nunito-sans';
@import '@fontsource/instrument-serif';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
@ -343,16 +341,6 @@
overscroll-behavior: contain;
}
/* Large modal variant for file previews */
.modal-large .modal-box {
@apply max-w-[90vw] max-h-[90vh] p-4;
}
/* Extra large modal for maximum viewing space */
.modal-xlarge .modal-box {
@apply max-w-[95vw] max-h-[95vh] p-2;
}
.modal-top {
@apply place-items-start;

View file

@ -96,15 +96,12 @@ export const createAndAddMessage = mutation({
role: messageRoleValidator,
session_token: v.string(),
web_search_enabled: v.optional(v.boolean()),
attachments: v.optional(
images: v.optional(
v.array(
v.object({
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
url: v.string(),
storage_id: v.string(),
fileName: v.string(),
mimeType: v.string(),
size: v.number(),
fileName: v.optional(v.string()),
})
)
),
@ -140,7 +137,7 @@ export const createAndAddMessage = mutation({
conversation_id: conversationId,
session_token: args.session_token,
web_search_enabled: args.web_search_enabled,
attachments: args.attachments,
images: args.images,
});
return {

View file

@ -2,7 +2,7 @@ import { v } from 'convex/values';
import { api } from './_generated/api';
import { type Id } from './_generated/dataModel';
import { query } from './_generated/server';
import { messageRoleValidator, providerValidator, reasoningEffortValidator } from './schema';
import { messageRoleValidator, providerValidator } from './schema';
import { mutation } from './functions';
export const getAllFromConversation = query({
@ -47,17 +47,13 @@ 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 attachments
attachments: v.optional(
// Optional image attachments
images: v.optional(
v.array(
v.object({
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
url: v.string(),
storage_id: v.string(),
fileName: v.string(),
mimeType: v.string(),
size: v.number(),
fileName: v.optional(v.string()),
})
)
),
@ -98,9 +94,8 @@ 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 attachments
attachments: args.attachments,
// Optional image attachments
images: args.images,
}),
ctx.db.patch(args.conversation_id as Id<'conversations'>, {
generating: true,
@ -117,11 +112,7 @@ 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, {
@ -140,11 +131,7 @@ export const updateContent = mutation({
await ctx.db.patch(message._id, {
content: args.content,
reasoning: args.reasoning,
content_html: args.content_html,
generation_id: args.generation_id,
annotations: args.annotations,
reasoning_effort: args.reasoning_effort,
});
},
});

View file

@ -8,11 +8,6 @@ export const messageRoleValidator = v.union(
v.literal('assistant'),
v.literal('system')
);
export const reasoningEffortValidator = v.union(
v.literal('low'),
v.literal('medium'),
v.literal('high')
);
export type MessageRole = Infer<typeof messageRoleValidator>;
@ -22,6 +17,7 @@ export default defineSchema({
user_settings: defineTable({
user_id: v.string(),
privacy_mode: v.boolean(),
free_messages_used: v.optional(v.number()),
}).index('by_user', ['user_id']),
user_keys: defineTable({
user_id: v.string(),
@ -35,8 +31,7 @@ export default defineSchema({
provider: providerValidator,
/** Different providers may use different ids for the same model */
model_id: v.string(),
// null is just here for compat we treat null as true
pinned: v.optional(v.union(v.boolean(), v.null())),
pinned: v.union(v.number(), v.null()),
})
.index('by_user', ['user_id'])
.index('by_model_provider', ['model_id', 'provider'])
@ -66,29 +61,23 @@ 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()),
provider: v.optional(providerValidator),
token_count: v.optional(v.number()),
// Optional attachments
attachments: v.optional(
// Optional image attachments
images: v.optional(
v.array(
v.object({
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
url: v.string(),
storage_id: v.string(),
fileName: v.string(),
mimeType: v.string(),
size: v.number(),
fileName: v.optional(v.string()),
})
)
),
cost_usd: v.optional(v.number()),
generation_id: v.optional(v.string()),
web_search_enabled: v.optional(v.boolean()),
reasoning_effort: v.optional(reasoningEffortValidator),
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
}).index('by_conversation', ['conversation_id']),
});

View file

@ -101,50 +101,20 @@ export const set = mutation({
)
.first();
if (args.enabled) {
// Enable model: insert if not exists
if (!existing) {
if (args.enabled && existing) return; // nothing to do here
if (existing) {
await ctx.db.delete(existing._id);
} else {
await ctx.db.insert('user_enabled_models', {
...object.pick(args, ['provider', 'model_id']),
user_id: session.userId,
pinned: false,
pinned: null,
});
}
} else {
// Disable model: delete if exists
if (existing) {
await ctx.db.delete(existing._id);
}
}
},
});
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(),
@ -180,7 +150,7 @@ export const enable_initial = mutation({
user_id: session.userId,
provider: Provider.OpenRouter,
model_id: model,
pinned: true,
pinned: null,
})
)
);

View file

@ -89,6 +89,26 @@ export const set = mutation({
await ctx.db.replace(existing._id, userKey);
} else {
await ctx.db.insert('user_keys', userKey);
if (args.provider === Provider.OpenRouter) {
const defaultModels = [
'google/gemini-2.5-flash',
'anthropic/claude-sonnet-4',
'openai/o3-mini',
'deepseek/deepseek-chat-v3-0324:free',
];
await Promise.all(
defaultModels.map((model) =>
ctx.db.insert('user_enabled_models', {
user_id: session.userId,
provider: Provider.OpenRouter,
model_id: model,
pinned: null,
})
)
);
}
}
},
});

View file

@ -26,6 +26,41 @@ export const get = query({
},
});
export const incrementFreeMessageCount = mutation({
args: {
session_token: v.string(),
},
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 s = session as SessionObj;
const existing = await ctx.db
.query('user_settings')
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
.first();
if (!existing) {
await ctx.db.insert('user_settings', {
user_id: s.userId,
privacy_mode: false,
free_messages_used: 1,
});
} else {
const currentCount = existing.free_messages_used || 0;
await ctx.db.patch(existing._id, {
free_messages_used: currentCount + 1,
});
}
},
});
export const set = mutation({
args: {
privacy_mode: v.boolean(),
@ -51,6 +86,7 @@ export const set = mutation({
await ctx.db.insert('user_settings', {
user_id: s.userId,
privacy_mode: args.privacy_mode,
free_messages_used: 0,
});
} else {
await ctx.db.patch(existing._id, {
@ -69,6 +105,7 @@ export const create = mutation({
await ctx.db.insert('user_settings', {
user_id: args.user_id,
privacy_mode: false,
free_messages_used: 0,
});
},
});

View file

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

View file

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

View file

@ -1,640 +0,0 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import Cohere from '$lib/components/icons/cohere.svelte';
import Deepseek from '$lib/components/icons/deepseek.svelte';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { models as modelsState } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
import { Provider, PROVIDER_META } from '$lib/types';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import {
supportsImages,
supportsReasoning,
supportsStreaming,
supportsToolCalls,
} 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';
import { isFirefox } from '$lib/hooks/is-firefox.svelte';
type Props = {
class?: string;
/* Required capabilities that the selected model must support */
requiredCapabilities?: Array<'vision' | 'audio' | 'video' | 'documents'>;
};
let { class: className, requiredCapabilities = [] }: Props = $props();
const client = useConvexClient();
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
session_token: session.current?.session.token ?? '',
});
// Get enabled models from our models state with ModelInfo data
const enabledArr = $derived.by(() => {
const enabledModelIds = Object.keys(enabledModelsQuery.data ?? {});
const enabledModels = modelsState
.all()
.filter((model) => enabledModelIds.some((id) => id.includes(model.id)));
return enabledModels;
});
modelsState.init();
// Company icon mapping
const companyIcons: Record<string, Component> = {
openai: OpenaiIcon,
anthropic: LogosClaudeIcon,
google: GoogleIcon,
meta: MetaIcon,
mistral: ZapIcon,
'x-ai': XIcon,
microsoft: MicrosoftIcon,
qwen: CpuIcon,
deepseek: Deepseek,
cohere: Cohere,
};
function getModelIcon(modelId: string): Component | null {
const id = modelId.toLowerCase();
// Model-specific icons take priority
if (id.includes('claude') || id.includes('anthropic')) return LogosClaudeIcon;
if (id.includes('gemini') || id.includes('gemma')) return MaterialIconThemeGeminiAi;
if (id.includes('mistral') || id.includes('mixtral')) return LogosMistralAiIcon;
// Fallback to company icons
const company = getCompanyFromModelId(modelId);
return companyIcons[company] || null;
}
function getCompanyFromModelId(modelId: string): string {
const id = modelId.toLowerCase();
if (id.includes('gpt') || id.includes('o1') || id.includes('openai')) return 'openai';
if (id.includes('claude') || id.includes('anthropic')) return 'anthropic';
if (
id.includes('gemini') ||
id.includes('gemma') ||
id.includes('google') ||
id.includes('palm')
)
return 'google';
if (id.includes('llama') || id.includes('meta')) return 'meta';
if (id.includes('mistral') || id.includes('mixtral')) return 'mistral';
if (id.includes('grok') || id.includes('x-ai')) return 'x-ai';
if (id.includes('phi') || id.includes('microsoft')) return 'microsoft';
if (id.includes('qwen') || id.includes('alibaba')) return 'qwen';
if (id.includes('deepseek')) return 'deepseek';
if (id.includes('command') || id.includes('cohere')) return 'cohere';
// Try to extract from model path (e.g., "anthropic/claude-3")
const pathParts = modelId.split('/');
if (pathParts.length > 1) {
const provider = pathParts[0]?.toLowerCase();
if (provider && companyIcons[provider]) return provider;
}
return 'other';
}
let search = $state('');
const filteredModels = $derived(
fuzzysearch({
haystack: enabledArr,
needle: search,
property: 'id',
})
);
// Group models by provider
const groupedModels = $derived.by(() => {
const groups: Record<Provider, typeof filteredModels> = {} as Record<
Provider,
typeof filteredModels
>;
filteredModels.forEach((model) => {
const provider = model.provider as Provider;
if (!groups[provider]) {
groups[provider] = [];
}
groups[provider].push(model);
});
// Sort by provider order and name
const result = Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([provider, models]) =>
[provider, models.sort((a, b) => a.name.localeCompare(b.name))] as [
Provider,
typeof models,
]
);
return result;
});
const currentModel = $derived(enabledArr.find((m) => m.id === settings.modelId));
$effect(() => {
if (!enabledArr.find((m) => m.id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.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(model: { id: string; name: string }) {
// Use the name field if available, fallback to processing ID
const displayName = model.name || model.id;
const cleanId = displayName.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.id === activeModel);
if (!model) return null;
return {
...model,
formatted: formatModelName(model),
};
});
// For now, we'll need to maintain pinned models using the old enabled models structure
// until we migrate the pinning system to work with the new ModelInfo structure
const enabledModelsData = $derived(Object.values(enabledModelsQuery.data ?? {}));
const pinnedModels = $derived(enabledModelsData.filter((m) => isPinned(m)));
function modelSupportsRequiredCapabilities(model: typeof enabledArr[number], required: Array<'vision' | 'audio' | 'video' | 'documents'>): boolean {
return required.every(capability => {
switch (capability) {
case 'vision': return model.capabilities.vision;
case 'audio': return model.capabilities.audio;
case 'video': return model.capabilities.video;
case 'documents': return model.capabilities.documents;
default: return false;
}
});
}
</script>
<svelte:window
use:shortcut={{
ctrl: true,
shift: true,
key: 'm',
callback: () => (open = true),
}}
/>
<Popover.Root bind:open>
{#if enabledArr.length}
<Popover.Trigger
class={cn(
'ring-offset-background focus:ring-ring flex items-center justify-between rounded-lg px-2 py-1 text-xs transition hover:text-white focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
<div class="flex items-center gap-2 pr-2">
{#if currentModel && getModelIcon(currentModel.id)}
{@const IconComponent = getModelIcon(currentModel.id)}
<IconComponent class="size-3" />
{/if}
<span class="truncate">
{currentModel ? formatModelName(currentModel).full : 'Select model'}
</span>
</div>
<ChevronDownIcon class="size-4 opacity-50" />
</Popover.Trigger>
<Popover.Content
portalProps={{
disabled: isFirefox,
}}
align="start"
sideOffset={5}
class={cn('p-0 transition-all', {
'w-[572px]': !isMobile.current && view === 'enabled',
'w-[300px]': view === 'favorites',
'max-w-[calc(100vw-2rem)]': isMobile.current,
})}
>
<Command.Root
class={cn('flex h-full w-full flex-col overflow-hidden')}
bind:value={activeModel}
columns={view === 'favorites' ? undefined : isMobile.current ? 2 : 4}
>
<label class="border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm">
<SearchIcon class="text-muted-foreground" />
<Command.Input
class="w-full outline-none"
placeholder="Search models..."
onkeydown={(e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
view = 'enabled';
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
view = 'favorites';
} else if (e.key === 'u') {
if (activeModelInfo) {
e.preventDefault();
e.stopPropagation();
togglePin(activeModelInfo._id);
}
}
}
}}
/>
</label>
<Command.List
class={cn('overflow-y-auto transition-all', {
'h-[430px]': view === 'enabled',
'flex flex-col gap-1 p-1': view === 'favorites',
})}
style="height: {view === 'enabled'
? '430px'
: `min(300px, ${pinnedModels.length * 44 + 4}px)`};"
>
{#if view === 'favorites' && pinnedModels.length > 0}
{#each pinnedModels as model (model._id)}
{@const modelInfo = enabledArr.find((m) => m.id === model.model_id)}
{@const formatted = modelInfo
? formatModelName(modelInfo)
: { full: model.model_id, primary: model.model_id, secondary: '' }}
{@const disabled = requiredCapabilities.length > 0 && modelInfo && !modelSupportsRequiredCapabilities(modelInfo, requiredCapabilities)}
<Command.Item
value={model.model_id}
class={cn(
'bg-popover flex rounded-lg p-2',
'relative scroll-m-36 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
'h-10 items-center justify-between',
disabled && 'opacity-50'
)}
onSelect={() => modelSelected(model.model_id)}
>
<div class={cn('flex items-center gap-2')}>
{#if getModelIcon(model.model_id)}
{@const ModelIcon = getModelIcon(model.model_id)}
<ModelIcon class="size-4 shrink-0" />
{/if}
<p class={cn('font-fake-proxima text-center text-sm leading-tight font-bold')}>
{formatted.full}
</p>
</div>
<div class="flex place-items-center gap-1">
{#if modelInfo && supportsImages(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports vision/image analysis
</Tooltip>
{/if}
{#if modelInfo && supportsReasoning(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
{#if modelInfo && supportsStreaming(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if modelInfo && supportsToolCalls(modelInfo)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
</Command.Item>
{/each}
{:else if view === 'enabled'}
{#if pinnedModels.length > 0}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
>
Pinned
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each pinnedModels as model (model._id)}
{@const modelInfo = enabledArr.find((m) => m.id === model.model_id)}
{#if modelInfo}
{@render modelCard(modelInfo)}
{/if}
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{#each groupedModels as [provider, models] (provider)}
{@const providerMeta = PROVIDER_META[provider]}
{#if models.length > 0}
<Command.Group class="space-y-2">
<Command.GroupHeading
class="text-heading/75 flex scroll-m-40 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide"
>
{providerMeta.title}
</Command.GroupHeading>
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
{#each models as model (model.id)}
{@render modelCard(model)}
{/each}
</Command.GroupItems>
</Command.Group>
{/if}
{/each}
{/if}
</Command.List>
</Command.Root>
<div class="border-border flex place-items-center justify-between border-t p-2">
<Button variant="ghost" size="sm" onclick={toggleView} class="h-7 text-sm font-normal">
<ChevronLeftIcon
class={cn('size-4 rotate-90 transition-all', { 'rotate-0': view === 'enabled' })}
/>
{view === 'favorites' ? 'Show enabled' : 'Show favorites'}
{#if !isMobile.current}
<span>
<Kbd size="xs">{cmdOrCtrl}</Kbd>
<Kbd size="xs">{view === 'favorites' ? '→' : '←'}</Kbd>
</span>
{/if}
</Button>
{#if !isMobile.current && activeModelInfo && view === 'enabled'}
<div>
<Button
variant="ghost"
loading={pinning}
class="bg-popover"
size="sm"
onclick={() => togglePin(activeModelInfo._id)}
>
<span class="text-muted-foreground">
{isPinned(activeModelInfo) ? 'Unpin' : 'Pin'}
</span>
<span>
<Kbd size="xs">{cmdOrCtrl}</Kbd>
<Kbd size="xs">U</Kbd>
</span>
</Button>
</div>
{/if}
</div>
</Popover.Content>
{/if}
</Popover.Root>
{#snippet modelCard(model: (typeof enabledArr)[number])}
{@const formatted = formatModelName(model)}
{@const disabled = requiredCapabilities.length > 0 && !modelSupportsRequiredCapabilities(model, requiredCapabilities)}
{@const enabledModelData = enabledModelsData.find((m) => m.model_id === model.id)}
<Command.Item
value={model.id}
class={cn(
'border-border bg-popover group/item flex gap-2 rounded-lg border p-2',
'relative scroll-m-36 select-none',
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
'h-36 w-32 flex-col items-center justify-center',
disabled && 'opacity-50'
)}
onSelect={() => modelSelected(model.id)}
>
<div class={cn('flex flex-col items-center')}>
{#if getModelIcon(model.id)}
{@const ModelIcon = getModelIcon(model.id)}
<ModelIcon class="size-6 shrink-0" />
{/if}
<p
class={cn(
'font-fake-proxima mt-2 text-center text-sm leading-tight font-medium md:mt-0 md:text-base md:font-bold'
)}
>
{isMobile.current ? formatted.full : formatted.primary}
</p>
<p class="mt-0 hidden text-center text-xs leading-tight font-medium md:block">
{formatted.secondary}
</p>
</div>
<div class="flex place-items-center gap-1">
{#if supportsImages(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports vision/image analysis
</Tooltip>
{/if}
{#if supportsReasoning(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
{#if supportsStreaming(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if supportsToolCalls(model)}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
{#if enabledModelData}
<div
class="bg-popover absolute top-1 right-1 scale-75 rounded-md p-1 transition-all group-hover/item:scale-100 group-hover/item:opacity-100 md:opacity-0"
>
<Button
variant="ghost"
size="icon"
class="size-7"
onclick={(e: MouseEvent) => {
e.stopPropagation();
togglePin(enabledModelData._id);
}}
>
{#if isPinned(enabledModelData)}
<PinOffIcon class="size-4" />
{:else}
<PinIcon class="size-4" />
{/if}
</Button>
</div>
{/if}
</Command.Item>
{/snippet}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,512 +0,0 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import { cn } from '$lib/utils/utils';
import { Modal } from '$lib/components/ui/modal';
import { Button } from '$lib/components/ui/button';
import DownloadIcon from '~icons/lucide/download';
import ExternalLinkIcon from '~icons/lucide/external-link';
import XIcon from '~icons/lucide/x';
import MaximizeIcon from '~icons/lucide/maximize-2';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { formatFileSize, formatTime, openInNewTab } from '$lib/utils/file';
import { untrack } from 'svelte';
let { attachment, isUserMessage = false, compact = false } = $props<{
attachment: Attachment;
isUserMessage?: boolean;
compact?: boolean;
}>();
let audioUrl = $derived(attachment.url);
let fileName = $derived(attachment.fileName);
let fileSize = $derived(attachment.size);
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
let volume = $state(0.7);
let isMuted = $state(false);
let audioElement: HTMLAudioElement | undefined;
let modalOpen = $state(false);
const togglePlay = () => {
if (audioElement) {
if (audioElement.paused) {
audioElement.play();
isPlaying = true;
} else {
audioElement.pause();
isPlaying = false;
}
}
};
const toggleMute = () => {
if (audioElement) {
audioElement.muted = !audioElement.muted;
isMuted = audioElement.muted;
}
};
const handleTimeUpdate = () => {
if (audioElement) {
currentTime = audioElement.currentTime;
duration = audioElement.duration || 0;
}
};
const handleSeek = (e: Event) => {
const target = e.target as HTMLInputElement;
const newTime = parseFloat(target.value);
if (audioElement) {
audioElement.currentTime = newTime;
currentTime = newTime;
}
};
const handleVolumeChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const newVolume = parseFloat(target.value);
if (audioElement) {
audioElement.volume = newVolume;
volume = newVolume;
isMuted = newVolume === 0;
}
};
// Real waveform analysis using Web Audio API
let baseWaveformBars = $state(Array(40).fill(0.5));
let baseModalBars = $state(Array(80).fill(0.5));
async function analyzeAudio() {
try {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Get audio data from first channel
const channelData = audioBuffer.getChannelData(0);
const samples = channelData.length;
// Generate waveform for 40 bars
const samplesPerBar40 = Math.floor(samples / 40);
const waveform40 = Array(40).fill(0);
for (let i = 0; i < 40; i++) {
let sum = 0;
const start = i * samplesPerBar40;
const end = Math.min(start + samplesPerBar40, samples);
// Calculate RMS (root mean square) for this segment
for (let j = start; j < end; j++) {
sum += channelData[j] * channelData[j];
}
waveform40[i] = Math.min(1, Math.sqrt(sum / (end - start)) * 3); // Scale for visibility
}
// Generate waveform for 80 bars
const samplesPerBar80 = Math.floor(samples / 80);
const waveform80 = Array(80).fill(0);
for (let i = 0; i < 80; i++) {
let sum = 0;
const start = i * samplesPerBar80;
const end = Math.min(start + samplesPerBar80, samples);
for (let j = start; j < end; j++) {
sum += channelData[j] * channelData[j];
}
waveform80[i] = Math.min(1, Math.sqrt(sum / (end - start)) * 3);
}
baseWaveformBars = waveform40;
baseModalBars = waveform80;
audioContext.close();
} catch (error) {
console.warn('Could not analyze audio, using fallback waveform:', error);
// Fallback to pattern-based waveform
const generateFallback = (bars) => Array.from({ length: bars }, (_, i) => {
const baseHeight = Math.sin(i * 0.3) * 0.3 + 0.5;
const noise = (Math.random() - 0.5) * 0.3;
return Math.max(0.1, Math.min(1, baseHeight + noise));
});
baseWaveformBars = generateFallback(40);
baseModalBars = generateFallback(80);
}
}
// Analyze audio when component loads
$effect(() => {
if (audioUrl) {
analyzeAudio();
}
});
let animationFrame: number;
let animatedBars = $state([...baseWaveformBars]);
let animatedModalBars = $state([...baseModalBars]);
// Calculate progress outside of the each loop to avoid performance issues
let progress = $derived(currentTime / (duration || 1));
// Safe animation that doesn't cause reactive loops
$effect(() => {
if (isPlaying) {
const animate = () => {
const time = Date.now() * 0.01;
// Create new array instead of mutating existing one
const newBars = baseWaveformBars.map((baseHeight, i) => {
const activeBar = Math.floor(progress * baseWaveformBars.length);
if (i <= activeBar) {
// Add subtle animation to played portion
const variation = (Math.sin(time + i * 0.5) * 0.1);
return Math.max(0.3, Math.min(1, baseHeight + variation));
}
// Keep unplayed portion at reduced height
return baseHeight * 0.7;
});
// Animate modal bars too
const newModalBars = baseModalBars.map((baseHeight, i) => {
const activeBar = Math.floor(progress * baseModalBars.length);
if (i <= activeBar) {
// Add subtle animation to played portion
const variation = (Math.sin(time + i * 0.3) * 0.1);
return Math.max(0.3, Math.min(1, baseHeight + variation));
}
// Keep unplayed portion at reduced height
return baseHeight * 0.7;
});
// Use untrack to prevent reactive updates from triggering this effect
untrack(() => {
animatedBars = newBars;
animatedModalBars = newModalBars;
});
animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
} else {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
// Reset to base heights when not playing
animatedBars = [...baseWaveformBars];
animatedModalBars = [...baseModalBars];
}
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
});
</script>
<audio
bind:this={audioElement}
src={audioUrl}
onplay={() => (isPlaying = true)}
onpause={() => (isPlaying = false)}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={() => {
if (audioElement) {
duration = audioElement.duration || 0;
audioElement.volume = volume;
}
}}
onvolumechange={() => {
if (audioElement) {
volume = audioElement.volume;
isMuted = audioElement.muted;
}
}}
onended={() => (isPlaying = false)}
/>
<div class="flex flex-col gap-3">
{#if compact}
<!-- Compact view for inline display -->
<div class="flex items-center gap-3">
<button
onclick={togglePlay}
class="flex-shrink-0 w-10 h-10 bg-primary/10 hover:bg-primary/20 rounded-full flex items-center justify-center transition-colors"
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
>
{#if isPlaying}
<svg class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">
Audio • {formatFileSize(fileSize)}
</div>
</div>
<button
onclick={() => (modalOpen = true)}
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
aria-label="Open audio in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
</div>
{:else}
<!-- Full view for dedicated audio display -->
<div class="bg-card border border-border rounded-lg p-4">
<!-- Audio header with icon and info -->
<div class="flex items-center gap-3 mb-4">
<div class="flex-shrink-0 w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">
{formatFileSize(fileSize)}{formatTime(duration)}
</div>
</div>
<div class="flex items-center gap-1">
<button
onclick={() => (modalOpen = true)}
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
aria-label="Open audio in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
<a
href={audioUrl}
download={fileName}
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
aria-label={`Download ${fileName}`}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
</div>
<!-- Waveform visualization -->
<div class="mb-4 h-16 flex items-center gap-1 px-2">
{#each animatedBars as height, i}
{@const isActive = i <= Math.floor(progress * animatedBars.length)}
<div
class="flex-1 rounded-sm transition-all duration-200 {isActive ? 'bg-primary' : 'bg-primary/20'}"
style="height: {height * 100}%"
/>
{/each}
</div>
<!-- Progress bar -->
<div class="mb-3">
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeek}
class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 transition-transform"
aria-label="Audio progress"
/>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button
onclick={togglePlay}
class="w-10 h-10 bg-primary hover:bg-primary/90 rounded-full flex items-center justify-center transition-colors"
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
>
{#if isPlaying}
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<button
onclick={toggleMute}
class="w-8 h-8 bg-muted hover:bg-muted/80 rounded-full flex items-center justify-center transition-colors"
aria-label={isMuted ? 'Unmute audio' : 'Mute audio'}
>
{#if isMuted}
<svg class="w-4 h-4 text-foreground" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-foreground" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
oninput={handleVolumeChange}
class="w-20 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
aria-label="Volume control"
/>
</div>
</div>
</div>
{/if}
<!-- Modal for enhanced audio viewing -->
<Modal bind:open={modalOpen} class="modal-large">
<div class="flex items-center justify-between p-2">
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
<div class="flex items-center gap-2 flex-shrink-0">
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
download={fileName}
href={audioUrl}
{...tooltip.trigger}
>
<DownloadIcon class="size-4" />
</Button>
{/snippet}
Download audio
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(audioUrl)} {...tooltip.trigger}>
<ExternalLinkIcon class="size-4" />
</Button>
{/snippet}
Open in new tab
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
onclick={() => (modalOpen = false)}
{...tooltip.trigger}
>
<XIcon class="size-4" />
</Button>
{/snippet}
Close
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-6 p-6">
<!-- Enhanced waveform visualization -->
<div class="h-24 flex items-center gap-1 px-4 bg-muted/20 rounded-lg">
{#each animatedModalBars as height, i}
{@const isActive = i <= Math.floor(progress * animatedModalBars.length)}
<div
class="flex-1 rounded-sm transition-all duration-300 {isActive ? 'bg-primary' : 'bg-primary/30'}"
style="height: {height * 100}%"
/>
{/each}
</div>
<!-- Enhanced audio player -->
<div class="bg-card border border-border rounded-lg p-6">
<!-- Progress bar -->
<div class="mb-4">
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeek}
class="w-full h-3 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 transition-transform"
aria-label="Audio progress"
/>
<div class="flex justify-between text-sm text-muted-foreground mt-2">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<button
onclick={togglePlay}
class="w-12 h-12 bg-primary hover:bg-primary/90 rounded-full flex items-center justify-center transition-colors"
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
>
{#if isPlaying}
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<div class="flex items-center gap-3">
<button
onclick={toggleMute}
class="w-10 h-10 bg-muted hover:bg-muted/80 rounded-full flex items-center justify-center transition-colors"
aria-label={isMuted ? 'Unmute audio' : 'Mute audio'}
>
{#if isMuted}
<svg class="w-5 h-5 text-foreground" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5 text-foreground" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
oninput={handleVolumeChange}
class="w-24 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
aria-label="Volume control"
/>
</div>
</div>
<div class="text-sm text-muted-foreground">
{formatFileSize(fileSize)}{formatTime(duration)}
</div>
</div>
</div>
</div>
</Modal>
</div>

View file

@ -1,379 +0,0 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import { cn } from '$lib/utils/utils';
import { Modal } from '$lib/components/ui/modal';
import { Button } from '$lib/components/ui/button';
import DownloadIcon from '~icons/lucide/download';
import ExternalLinkIcon from '~icons/lucide/external-link';
import XIcon from '~icons/lucide/x';
import MaximizeIcon from '~icons/lucide/maximize-2';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { formatFileSize, openInNewTab, getFileExtension } from '$lib/utils/file';
let { attachment, isUserMessage = false, compact = false } = $props<{
attachment: Attachment;
isUserMessage?: boolean;
compact?: boolean;
}>();
let documentUrl = $derived(attachment.url);
let fileName = $derived(attachment.fileName);
let fileSize = $derived(attachment.size);
let mimeType = $derived(attachment.mimeType);
let modalOpen = $state(false);
const getFileIcon = () => {
const extension = getFileExtension(fileName);
// PDF files
if (extension === 'pdf') {
return {
icon: '📄',
color: 'text-red-500',
bgColor: 'bg-red-50',
label: 'PDF Document'
};
}
// Text files
if (['txt', 'md', 'markdown', 'rtf'].includes(extension)) {
return {
icon: '📝',
color: 'text-blue-500',
bgColor: 'bg-blue-50',
label: 'Text Document'
};
}
// Word documents
if (['doc', 'docx'].includes(extension)) {
return {
icon: '📘',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
label: 'Word Document'
};
}
// Excel spreadsheets
if (['xls', 'xlsx', 'csv'].includes(extension)) {
return {
icon: '📗',
color: 'text-green-600',
bgColor: 'bg-green-50',
label: 'Spreadsheet'
};
}
// PowerPoint presentations
if (['ppt', 'pptx'].includes(extension)) {
return {
icon: '📙',
color: 'text-orange-600',
bgColor: 'bg-orange-50',
label: 'Presentation'
};
}
// Code files
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'scss', 'json', 'xml', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt', 'sql'].includes(extension)) {
return {
icon: '💻',
color: 'text-purple-600',
bgColor: 'bg-purple-50',
label: 'Code File'
};
}
// Archive files
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(extension)) {
return {
icon: '📦',
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
label: 'Archive'
};
}
// Default document
return {
icon: '📄',
color: 'text-gray-600',
bgColor: 'bg-gray-50',
label: 'Document'
};
};
let fileDetails = $derived(getFileIcon());
</script>
<div class="flex flex-col gap-3">
{#if compact}
<!-- Compact view for inline display -->
<div class="flex items-center gap-2">
<div class={`flex-shrink-0 w-10 h-10 ${fileDetails.bgColor} rounded-lg flex items-center justify-center`}>
<span class="text-lg">{fileDetails.icon}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">
{fileDetails.label}{formatFileSize(fileSize)}
</div>
</div>
<button
onclick={() => (modalOpen = true)}
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
aria-label="Open document in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
</div>
{:else}
<!-- Full view for dedicated document display -->
<div class="bg-card border border-border rounded-lg p-4">
<div class="flex items-start gap-4">
<!-- Document icon -->
<div class={`flex-shrink-0 w-16 h-16 ${fileDetails.bgColor} rounded-xl flex items-center justify-center`}>
<span class="text-2xl">{fileDetails.icon}</span>
</div>
<!-- Document info -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-foreground truncate">
{fileName}
</h3>
<p class="text-xs text-muted-foreground mt-1">
{fileDetails.label}
</p>
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span>{formatFileSize(fileSize)}</span>
<span>{mimeType}</span>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Maximize button -->
<button
onclick={() => (modalOpen = true)}
class="p-2 rounded-lg hover:bg-accent transition-colors"
aria-label={`Open ${fileName} in large modal`}
title="Open in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
{#if mimeType.startsWith('image/') || mimeType === 'application/pdf'}
<!-- Preview button for viewable documents -->
<button
onclick={() => openInNewTab(documentUrl)}
class="p-2 rounded-lg hover:bg-accent transition-colors"
aria-label={`Preview ${fileName}`}
title="Preview document"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
{/if}
<!-- Download button -->
<a
href={documentUrl}
download={fileName}
class="p-2 rounded-lg hover:bg-accent transition-colors"
aria-label={`Download ${fileName}`}
title="Download document"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
</div>
<!-- Additional metadata -->
<div class="mt-3 pt-3 border-t border-border">
<div class="flex items-center justify-between text-xs">
<div class="flex items-center gap-2">
<span class="inline-flex items-center px-2 py-1 rounded-full bg-muted text-muted-foreground">
{fileDetails.label}
</span>
{#if fileName.includes('.')}
<span class="inline-flex items-center px-2 py-1 rounded-full bg-muted text-muted-foreground">
.{fileName.split('.').pop()?.toUpperCase()}
</span>
{/if}
</div>
<div class="text-muted-foreground">
{formatFileSize(fileSize)}
</div>
</div>
</div>
</div>
</div>
<!-- Preview hint for viewable documents -->
{#if mimeType.startsWith('image/') || mimeType === 'application/pdf'}
<div class="mt-3 p-3 bg-muted/50 rounded-lg">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Click the eye icon to preview this document in a new tab</span>
</div>
</div>
{/if}
</div>
{/if}
<!-- Modal for enhanced document viewing -->
<Modal bind:open={modalOpen} class="modal-large">
<div class="flex items-center justify-between p-2">
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
<div class="flex items-center gap-2 flex-shrink-0">
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
download={fileName}
href={documentUrl}
{...tooltip.trigger}
>
<DownloadIcon class="size-4" />
</Button>
{/snippet}
Download document
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(documentUrl)} {...tooltip.trigger}>
<ExternalLinkIcon class="size-4" />
</Button>
{/snippet}
Open in new tab
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
onclick={() => (modalOpen = false)}
{...tooltip.trigger}
>
<XIcon class="size-4" />
</Button>
{/snippet}
Close
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-6 p-6">
<!-- Document header with enhanced info -->
<div class="flex items-start gap-6">
<div class={`flex-shrink-0 w-20 h-20 ${fileDetails.bgColor} rounded-2xl flex items-center justify-center`}>
<span class="text-3xl">{fileDetails.icon}</span>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-foreground truncate mb-2">
{fileName}
</h3>
<div class="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span class="inline-flex items-center px-3 py-1 rounded-full bg-muted text-muted-foreground">
{fileDetails.label}
</span>
{#if fileName.includes('.')}
<span class="inline-flex items-center px-3 py-1 rounded-full bg-muted text-muted-foreground">
.{fileName.split('.').pop()?.toUpperCase()}
</span>
{/if}
<span>{formatFileSize(fileSize)}</span>
<span>{mimeType}</span>
</div>
</div>
</div>
<!-- Document preview area -->
<div class="bg-muted/20 rounded-lg p-8 min-h-[400px] flex items-center justify-center">
{#if mimeType.startsWith('image/')}
<!-- Image preview -->
<img
src={documentUrl}
alt={fileName}
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
/>
{:else if mimeType === 'application/pdf'}
<!-- PDF preview -->
<div class="text-center">
<div class={`w-16 h-16 ${fileDetails.bgColor} rounded-full flex items-center justify-center mx-auto mb-4`}>
<span class="text-2xl">{fileDetails.icon}</span>
</div>
<h4 class="text-lg font-medium mb-2">PDF Document</h4>
<p class="text-muted-foreground mb-4">
This PDF document will open in a new tab for the best viewing experience.
</p>
<Button onclick={() => openInNewTab(documentUrl)} variant="outline">
Open PDF in new tab
<ExternalLinkIcon class="w-4 h-4 ml-2" />
</Button>
</div>
{:else}
<!-- Generic document preview -->
<div class="text-center">
<div class={`w-16 h-16 ${fileDetails.bgColor} rounded-full flex items-center justify-center mx-auto mb-4`}>
<span class="text-2xl">{fileDetails.icon}</span>
</div>
<h4 class="text-lg font-medium mb-2">{fileDetails.label}</h4>
<p class="text-muted-foreground mb-4">
This {fileDetails.label.toLowerCase()} can be downloaded for viewing in the appropriate application.
</p>
<div class="flex items-center justify-center gap-3">
<Button onclick={() => openInNewTab(documentUrl)} variant="outline">
Open in new tab
<ExternalLinkIcon class="w-4 h-4 ml-2" />
</Button>
<Button download={fileName} href={documentUrl}>
Download
<DownloadIcon class="w-4 h-4 ml-2" />
</Button>
</div>
</div>
{/if}
</div>
<!-- Document details -->
<div class="bg-card border border-border rounded-lg p-4">
<h4 class="font-medium mb-3">Document Details</h4>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div class="text-muted-foreground">Type</div>
<div class="font-medium">{fileDetails.label}</div>
</div>
<div>
<div class="text-muted-foreground">Size</div>
<div class="font-medium">{formatFileSize(fileSize)}</div>
</div>
<div>
<div class="text-muted-foreground">Format</div>
<div class="font-medium">{mimeType}</div>
</div>
{#if fileName.includes('.')}
<div>
<div class="text-muted-foreground">Extension</div>
<div class="font-medium">.{fileName.split('.').pop()?.toUpperCase()}</div>
</div>
{/if}
</div>
</div>
</div>
</Modal>
</div>

View file

@ -1,97 +0,0 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import ImagePreview from './image-preview.svelte';
import VideoPreview from './video-preview.svelte';
import AudioPreview from './audio-preview.svelte';
import DocumentPreview from './document-preview.svelte';
import { cn } from '$lib/utils/utils';
import { formatFileSize } from '$lib/utils/file';
let { attachment, isUserMessage = false, compact = false } = $props<{
attachment: Attachment;
isUserMessage?: boolean;
compact?: boolean;
}>();
let fileType = $derived(attachment.type);
let fileName = $derived(attachment.fileName);
let fileSize = $derived(attachment.size);
let mimeType = $derived(attachment.mimeType);
// Get appropriate icon based on file type
function getFileIcon() {
switch (fileType) {
case 'image':
return '🖼️';
case 'video':
return '🎥';
case 'audio':
return '🎵';
case 'document':
return '📄';
default:
return '📎';
}
}
</script>
<div
class={cn(
'group relative flex flex-col overflow-hidden rounded-lg border transition-all duration-200',
compact
? 'bg-background/50 border-border/50 p-2 hover:bg-accent/5'
: 'bg-card border-border p-4 hover:border-border/80 hover:shadow-sm',
isUserMessage && 'bg-primary/5 border-primary/20'
)}
role="figure"
aria-label={`File attachment: ${fileName}`}
>
{#if fileType === 'image'}
<ImagePreview
{attachment}
{isUserMessage}
{compact}
alt={fileName}
/>
{:else if fileType === 'video'}
<VideoPreview
{attachment}
{isUserMessage}
{compact}
/>
{:else if fileType === 'audio'}
<AudioPreview
{attachment}
{isUserMessage}
{compact}
/>
{:else if fileType === 'document'}
<DocumentPreview
{attachment}
{isUserMessage}
{compact}
/>
{:else}
<!-- Fallback for unknown file types -->
<div class="flex items-center gap-3 p-4">
<div class="flex-shrink-0 text-2xl">{getFileIcon()}</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">
{formatFileSize(fileSize)}{mimeType}
</div>
</div>
<a
href={attachment.url}
download={fileName}
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
aria-label={`Download ${fileName}`}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
{/if}
</div>

View file

@ -1,149 +0,0 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import { cn } from '$lib/utils/utils';
import { Modal } from '$lib/components/ui/modal';
import { Button } from '$lib/components/ui/button';
import DownloadIcon from '~icons/lucide/download';
import ExternalLinkIcon from '~icons/lucide/external-link';
import XIcon from '~icons/lucide/x';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { openInNewTab } from '$lib/utils/file';
let { attachment, isUserMessage = false, compact = false, alt = '' } = $props<{
attachment: Attachment;
isUserMessage?: boolean;
compact?: boolean;
alt?: string;
}>();
let modalOpen = $state(false);
let imageUrl = $derived(attachment.url);
let fileName = $derived(attachment.fileName);
</script>
<div class="relative group">
{#if compact}
<!-- Compact view for inline display -->
<div class="flex items-center gap-2">
<div
class="relative overflow-hidden rounded cursor-pointer hover:opacity-90 transition-opacity"
onclick={() => (modalOpen = true)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && (modalOpen = true)}
aria-label={`View full size image: ${fileName}`}
>
<img
src={imageUrl}
alt={alt}
class="w-16 h-16 object-cover rounded"
loading="lazy"
/>
<div class="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">Image</div>
</div>
</div>
{:else}
<!-- Full view for dedicated image display -->
<div
class="relative overflow-hidden rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onclick={() => (modalOpen = true)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && (modalOpen = true)}
aria-label={`View full size image: ${fileName}`}
>
<img
src={imageUrl}
alt={alt}
class="w-full h-auto max-h-[400px] object-contain rounded-lg bg-muted/20"
loading="lazy"
/>
<div class="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
<!-- Image info overlay -->
<div class="mt-2 flex items-center justify-between">
<div class="text-sm text-muted-foreground truncate">
{fileName}
</div>
<a
href={imageUrl}
download={fileName}
class="p-1 rounded hover:bg-accent transition-colors"
aria-label={`Download ${fileName}`}
onclick={(e) => e.stopPropagation()}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
{/if}
<!-- Modal for full-size view -->
<Modal bind:open={modalOpen} class="modal-large">
<div class="flex items-center justify-between p-2">
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
<div class="flex items-center gap-2 flex-shrink-0">
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
download={fileName}
href={imageUrl}
{...tooltip.trigger}
>
<DownloadIcon class="size-4" />
</Button>
{/snippet}
Download image
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(imageUrl)} {...tooltip.trigger}>
<ExternalLinkIcon class="size-4" />
</Button>
{/snippet}
Open in new tab
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
onclick={() => (modalOpen = false)}
{...tooltip.trigger}
>
<XIcon class="size-4" />
</Button>
{/snippet}
Close
</Tooltip>
</div>
</div>
<div class="flex items-center justify-center p-4 bg-muted/20 rounded-lg min-h-[400px]">
<img
src={imageUrl}
alt={alt}
class="max-h-[75vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
/>
</div>
</Modal>
</div>

View file

@ -1,321 +0,0 @@
<script lang="ts">
import type { Attachment } from '$lib/types';
import { cn } from '$lib/utils/utils';
import { Modal } from '$lib/components/ui/modal';
import { Button } from '$lib/components/ui/button';
import DownloadIcon from '~icons/lucide/download';
import ExternalLinkIcon from '~icons/lucide/external-link';
import XIcon from '~icons/lucide/x';
import MaximizeIcon from '~icons/lucide/maximize-2';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import { formatFileSize, formatTime, openInNewTab } from '$lib/utils/file';
let { attachment, isUserMessage = false, compact = false } = $props<{
attachment: Attachment;
isUserMessage?: boolean;
compact?: boolean;
}>();
let videoUrl = $derived(attachment.url);
let fileName = $derived(attachment.fileName);
let fileSize = $derived(attachment.size);
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
let volume = $state(1);
let isMuted = $state(false);
let videoElement: HTMLVideoElement | undefined;
let modalOpen = $state(false);
const togglePlay = () => {
if (videoElement) {
if (videoElement.paused) {
videoElement.play();
isPlaying = true;
} else {
videoElement.pause();
isPlaying = false;
}
}
};
const toggleMute = () => {
if (videoElement) {
videoElement.muted = !videoElement.muted;
isMuted = videoElement.muted;
}
};
const handleTimeUpdate = () => {
if (videoElement) {
currentTime = videoElement.currentTime;
duration = videoElement.duration || 0;
}
};
const handleSeek = (e: Event) => {
const target = e.target as HTMLInputElement;
const newTime = parseFloat(target.value);
if (videoElement) {
videoElement.currentTime = newTime;
currentTime = newTime;
}
};
const handleVolumeChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const newVolume = parseFloat(target.value);
if (videoElement) {
videoElement.volume = newVolume;
volume = newVolume;
isMuted = newVolume === 0;
}
};
</script>
<div class="flex flex-col gap-3">
{#if compact}
<!-- Compact view for inline display -->
<div class="flex items-center gap-2">
<div class="relative">
<video
bind:this={videoElement}
src={videoUrl}
class="w-16 h-16 object-cover rounded bg-muted/20"
poster=""
onplay={() => (isPlaying = true)}
onpause={() => (isPlaying = false)}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={() => {
if (videoElement) duration = videoElement.duration || 0;
}}
onvolumechange={() => {
if (videoElement) {
volume = videoElement.volume;
isMuted = videoElement.muted;
}
}}
/>
<button
onclick={togglePlay}
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 hover:opacity-100 transition-opacity"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{#if isPlaying}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{/if}
</button>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{fileName}</div>
<div class="text-xs text-muted-foreground">
Video • {formatFileSize(fileSize)}
</div>
</div>
<button
onclick={() => (modalOpen = true)}
class="flex-shrink-0 p-1 rounded hover:bg-accent transition-colors"
aria-label="Open video in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
</div>
{:else}
<!-- Full view for dedicated video display -->
<div class="relative overflow-hidden rounded-lg bg-muted/20">
<video
bind:this={videoElement}
src={videoUrl}
class="w-full h-auto max-h-[400px] object-contain"
controls={false}
onplay={() => (isPlaying = true)}
onpause={() => (isPlaying = false)}
ontimeupdate={handleTimeUpdate}
onloadedmetadata={() => {
if (videoElement) duration = videoElement.duration || 0;
}}
onvolumechange={() => {
if (videoElement) {
volume = videoElement.volume;
isMuted = videoElement.muted;
}
}}
/>
<!-- Custom video controls overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<!-- Progress bar -->
<div class="mb-3">
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
oninput={handleSeek}
class="w-full h-1 bg-white/30 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
aria-label="Video progress"
/>
<div class="flex justify-between text-xs text-white/80 mt-1">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button
onclick={togglePlay}
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{#if isPlaying}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<button
onclick={toggleMute}
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
aria-label={isMuted ? 'Unmute video' : 'Mute video'}
>
{#if isMuted}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
oninput={handleVolumeChange}
class="w-16 h-1 bg-white/30 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
aria-label="Volume control"
/>
</div>
<a
href={videoUrl}
download={fileName}
class="w-8 h-8 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors"
aria-label={`Download ${fileName}`}
>
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
</div>
</div>
<!-- Video info -->
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
{fileName}{formatFileSize(fileSize)}
</div>
<button
onclick={() => (modalOpen = true)}
class="flex-shrink-0 p-2 rounded hover:bg-accent transition-colors"
aria-label="Open video in large modal"
>
<MaximizeIcon class="w-4 h-4" />
</button>
</div>
{/if}
<!-- Modal for full-screen video viewing -->
<Modal bind:open={modalOpen} class="modal-large">
<div class="flex items-center justify-between p-2">
<h2 class="text-lg font-semibold truncate">{fileName}</h2>
<div class="flex items-center gap-2 flex-shrink-0">
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
download={fileName}
href={videoUrl}
{...tooltip.trigger}
>
<DownloadIcon class="size-4" />
</Button>
{/snippet}
Download video
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button size="iconSm" variant="outline" onclick={() => openInNewTab(videoUrl)} {...tooltip.trigger}>
<ExternalLinkIcon class="size-4" />
</Button>
{/snippet}
Open in new tab
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<Button
size="iconSm"
variant="outline"
onclick={() => (modalOpen = false)}
{...tooltip.trigger}
>
<XIcon class="size-4" />
</Button>
{/snippet}
Close
</Tooltip>
</div>
</div>
<div class="flex items-center justify-center p-4 bg-black rounded-lg min-h-[500px]">
<video
bind:this={videoElement}
src={videoUrl}
class="max-h-[80vh] max-w-full rounded-lg"
controls={true}
autoplay={false}
onplay={() => (isPlaying = true)}
onpause={() => (isPlaying = false)}
ontimeupdate={() => {
if (videoElement) {
currentTime = videoElement.currentTime;
duration = videoElement.duration || 0;
}
}}
onloadedmetadata={() => {
if (videoElement) {
duration = videoElement.duration || 0;
videoElement.volume = volume;
}
}}
onvolumechange={() => {
if (videoElement) {
volume = videoElement.volume;
isMuted = videoElement.muted;
}
}}
/>
</div>
</Modal>
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
// Because Firefox is stupid
/** Attempts to determine if a user is using Firefox using `navigator.userAgent`. */
export const isFirefox = navigator.userAgent.includes('Mozilla');

View file

@ -1,160 +0,0 @@
import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api';
import { Provider } from '$lib/types';
import { createModelManager } from './model-manager';
import type { UserApiKeys } from './model-manager';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import { ResultAsync } from 'neverthrow';
export interface MultiProviderModels {
[Provider.OpenAI]?: ModelInfo[];
[Provider.Anthropic]?: ModelInfo[];
[Provider.Gemini]?: ModelInfo[];
[Provider.Mistral]?: ModelInfo[];
[Provider.Cohere]?: ModelInfo[];
[Provider.OpenRouter]?: ModelInfo[];
}
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
// Cache models for 10 minutes to avoid excessive API calls
const MODEL_CACHE_TTL = 10 * 60 * 1000;
const modelCache = new Map<string, { models: MultiProviderModels; timestamp: number }>();
/**
* Load models for a specific user based on their API keys
*/
export async function loadUserModels(sessionToken: string): Promise<MultiProviderModels> {
const cacheKey = sessionToken;
const cached = modelCache.get(cacheKey);
// Return cached models if still valid
if (cached && Date.now() - cached.timestamp < MODEL_CACHE_TTL) {
console.log('Returning cached models:', Object.keys(cached.models));
return cached.models;
}
try {
// Get user's API keys
const userApiKeys = await getUserApiKeys(sessionToken);
console.log('getUserApiKeys result:', userApiKeys);
if (!userApiKeys) {
console.log('No user API keys found');
return {};
}
// Initialize ModelManager with user's API keys
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
console.log('Enabled providers:', modelManager.getEnabledProviders());
// Load models from all enabled providers
const models: MultiProviderModels = {};
const enabledProviders = modelManager.getEnabledProviders();
console.log('Loading models from providers:', enabledProviders);
const results = await Promise.allSettled(
enabledProviders.map(async (provider) => {
try {
console.log(`Loading models from ${provider}...`);
const providerModels = await modelManager.getModelsByProvider(provider);
console.log(`${provider} returned ${providerModels.length} models`);
if (providerModels.length > 0) {
models[provider] = providerModels;
}
return { provider, count: providerModels.length };
} catch (error) {
console.warn(`Failed to load models from ${provider}:`, error);
return { provider, error: error.message };
}
})
);
console.log('Model loading results:', results);
console.log('Final models object:', Object.keys(models));
// Cache the results
modelCache.set(cacheKey, {
models,
timestamp: Date.now(),
});
return models;
} catch (error) {
console.error('Failed to load user models:', error);
return {};
}
}
/**
* Load models without authentication (fallback to empty state)
*/
export function loadGuestModels(): MultiProviderModels {
return {};
}
/**
* Clear model cache for a specific user or all users
*/
export function clearModelCache(sessionToken?: string): void {
if (sessionToken) {
modelCache.delete(sessionToken);
} else {
modelCache.clear();
}
}
/**
* Get user's API keys from Convex
*/
async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
console.error('Failed to get user API keys:', keysResult.error);
return null;
}
const keys = keysResult.value;
return {
openai: keys.openai,
anthropic: keys.anthropic,
google: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
};
}
/**
* Get models for a specific provider (useful for partial loading)
*/
export async function loadProviderModels(
sessionToken: string,
provider: Provider
): Promise<ModelInfo[]> {
try {
const userApiKeys = await getUserApiKeys(sessionToken);
if (!userApiKeys) {
return [];
}
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
if (!modelManager.hasProviderEnabled(provider)) {
return [];
}
return await modelManager.getModelsByProvider(provider);
} catch (error) {
console.error(`Failed to load models from ${provider}:`, error);
return [];
}
}

View file

@ -1,130 +0,0 @@
import {
ModelManager,
OpenAIProvider,
AnthropicProvider,
GeminiProvider,
MistralProvider,
CohereProvider,
OpenRouterProvider,
type ProviderAdapter,
type ModelInfo,
} from '@keplersystems/kepler-ai-sdk';
import type { Provider } from '$lib/types';
export interface ProviderConfig {
apiKey: string;
baseURL?: string;
}
export interface UserApiKeys {
openai?: string;
anthropic?: string;
google?: string;
mistral?: string;
cohere?: string;
openrouter?: string;
}
export class ChatModelManager {
private modelManager: ModelManager;
private enabledProviders: Map<Provider, ProviderAdapter> = new Map();
constructor() {
this.modelManager = new ModelManager();
}
initializeProviders(userApiKeys: UserApiKeys): void {
this.enabledProviders.clear();
if (userApiKeys.openai) {
const provider = new OpenAIProvider({
apiKey: userApiKeys.openai,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('openai', provider);
}
if (userApiKeys.anthropic) {
const provider = new AnthropicProvider({
apiKey: userApiKeys.anthropic,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('anthropic', provider);
}
if (userApiKeys.google) {
const provider = new GeminiProvider({
apiKey: userApiKeys.google,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('gemini', provider);
}
if (userApiKeys.mistral) {
const provider = new MistralProvider({
apiKey: userApiKeys.mistral,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('mistral', provider);
}
if (userApiKeys.cohere) {
const provider = new CohereProvider({
apiKey: userApiKeys.cohere,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('cohere', provider);
}
if (userApiKeys.openrouter) {
const provider = new OpenRouterProvider({
apiKey: userApiKeys.openrouter,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('openrouter', provider);
}
}
async getModel(modelId: string): Promise<ModelInfo | null> {
return await this.modelManager.getModel(modelId);
}
getProvider(providerName: string): ProviderAdapter | undefined {
return this.modelManager.getProvider(providerName);
}
async listAvailableModels(): Promise<ModelInfo[]> {
return await this.modelManager.listModels();
}
async getModelsByProvider(provider: Provider): Promise<ModelInfo[]> {
if (!this.hasProviderEnabled(provider)) {
return [];
}
const allModels = await this.listAvailableModels();
return allModels.filter((model) => model.provider === provider);
}
async getModelsByCapability(capability: string): Promise<ModelInfo[]> {
const allModels = await this.listAvailableModels();
return allModels.filter(
(model) => model.capabilities[capability as keyof typeof model.capabilities]
);
}
hasProviderEnabled(provider: Provider): boolean {
return this.enabledProviders.has(provider);
}
getEnabledProviders(): Provider[] {
return Array.from(this.enabledProviders.keys());
}
isModelAvailable(modelId: string): Promise<boolean> {
return this.getModel(modelId).then((model) => model !== null);
}
}
export const createModelManager = (): ChatModelManager => {
return new ChatModelManager();
};

View file

@ -26,7 +26,7 @@ type PersistedObjOptions<T> = {
syncTabs?: boolean;
};
export function createPersistedObj<T extends Record<string, unknown>>(
export function createPersistedObj<T extends object>(
key: string,
initialValue: T,
options: PersistedObjOptions<T> = {}
@ -37,7 +37,7 @@ export function createPersistedObj<T extends Record<string, unknown>>(
syncTabs = true,
} = options;
let current: Record<string, unknown> = initialValue;
let current = initialValue;
let storage: Storage | undefined;
let subscribe: VoidFunction | undefined;
let version = $state(0);
@ -47,18 +47,7 @@ export function createPersistedObj<T extends Record<string, unknown>>(
const existingValue = storage.getItem(key);
if (existingValue !== null) {
const deserialized = deserialize(existingValue);
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;
}
if (deserialized) current = deserialized;
} else {
serialize(initialValue);
}
@ -77,7 +66,7 @@ export function createPersistedObj<T extends Record<string, unknown>>(
version += 1;
}
function deserialize(value: string): Record<string, unknown> | undefined {
function deserialize(value: string): T | undefined {
try {
return serializer.deserialize(value);
} catch (error) {
@ -86,7 +75,7 @@ export function createPersistedObj<T extends Record<string, unknown>>(
}
}
function serialize(value: Record<string, unknown> | undefined): void {
function serialize(value: T | undefined): void {
try {
if (value != undefined) {
storage?.setItem(key, serializer.serialize(value));

View file

@ -1,23 +0,0 @@
import { Context } from 'runed';
class LastChatState {
constructor(readonly opts: { lastChat: string | null }) {}
get current() {
return this.opts.lastChat;
}
set current(chat: string | null) {
this.opts.lastChat = chat;
}
}
const ctx = new Context<LastChatState>('last-chat');
export function setupLastChat() {
return ctx.set(new LastChatState({ lastChat: null }));
}
export function useLastChat() {
return ctx.get();
}

View file

@ -1,17 +1,13 @@
import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api';
import { getModelKey } from '$lib/backend/convex/user_enabled_models';
import type { ProviderModelMap } from '$lib/backend/models/all';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { createInit } from '$lib/spells/create-init.svelte';
import { Provider } from '$lib/types';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import { watch } from 'runed';
import { session } from './session.svelte';
export interface ModelWithEnabledStatus extends ModelInfo {
enabled: boolean;
}
export class Models {
enabled = $state({} as Record<string, unknown>);
@ -27,103 +23,13 @@ export class Models {
);
});
/**
* Get models from a specific provider with enabled status
*/
from(provider: Provider): ModelWithEnabledStatus[] {
const providerModels = page.data.models[provider] || [];
return providerModels.map((model: ModelInfo) => ({
...model,
enabled: this.enabled[getModelKey({ provider, model_id: model.id })] !== undefined,
}));
}
/**
* Get all models from all providers with enabled status
*/
all(): ModelWithEnabledStatus[] {
const allModels: ModelWithEnabledStatus[] = [];
const availableProviders = Object.keys(page.data.models || {}) as Provider[];
for (const provider of availableProviders) {
const providerModels = this.from(provider);
allModels.push(...providerModels);
}
return allModels;
}
/**
* Get models that support specific capabilities
*/
withCapability(capability: keyof ModelInfo['capabilities']): ModelWithEnabledStatus[] {
return this.all().filter(model => model.capabilities[capability]);
}
/**
* Get enabled models from all providers
*/
enabledModels(): ModelWithEnabledStatus[] {
return this.all().filter(model => model.enabled);
}
/**
* Get enabled models from a specific provider
*/
enabledFrom(provider: Provider): ModelWithEnabledStatus[] {
return this.from(provider).filter(model => model.enabled);
}
/**
* Get available providers (providers that have models loaded)
*/
availableProviders(): Provider[] {
const models = page.data.models || {};
return Object.keys(models).filter(provider =>
models[provider as Provider] && models[provider as Provider].length > 0
) as Provider[];
}
/**
* Check if a provider has models loaded
*/
hasProvider(provider: Provider): boolean {
const models = page.data.models[provider];
return Array.isArray(models) && models.length > 0;
}
/**
* Search models across all providers
*/
search(query: string): ModelWithEnabledStatus[] {
const lowerQuery = query.toLowerCase();
return this.all().filter(model =>
model.name.toLowerCase().includes(lowerQuery) ||
model.id.toLowerCase().includes(lowerQuery) ||
model.description?.toLowerCase().includes(lowerQuery)
);
}
/**
* Get models sorted by preference (enabled first, then by provider order)
*/
sorted(): ModelWithEnabledStatus[] {
return this.all().sort((a, b) => {
// Enabled models first
if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1;
// Then by provider order
const providerOrder = Object.values(Provider);
const aProviderIndex = providerOrder.indexOf(a.provider as Provider);
const bProviderIndex = providerOrder.indexOf(b.provider as Provider);
if (aProviderIndex !== bProviderIndex) {
return aProviderIndex - bProviderIndex;
}
// Finally by name
return a.name.localeCompare(b.name);
});
from<P extends Provider>(provider: Provider) {
return page.data.models[provider].map((m: { id: string }) => {
return {
...m,
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined,
};
}) as Array<ProviderModelMap[P] & { enabled: boolean }>;
}
}

View file

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

View file

@ -1,12 +1,8 @@
import { z } from 'zod';
export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
OpenAI: 'openai',
Anthropic: 'anthropic',
Gemini: 'gemini',
Mistral: 'mistral',
Cohere: 'cohere',
OpenRouter: 'openrouter',
} as const;
export type Provider = (typeof Provider)[keyof typeof Provider];
@ -15,62 +11,6 @@ export type ProviderMeta = {
title: string;
link: string;
description: string;
placeholder: string;
};
export const UrlCitationSchema = z.object({
type: z.literal('url_citation'),
url_citation: z.object({
end_index: z.number(),
start_index: z.number(),
title: z.string(),
url: z.string(),
content: z.string(),
}),
});
export type UrlCitation = z.infer<typeof UrlCitationSchema>;
// if there are more types do this
// export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
export const AnnotationSchema = UrlCitationSchema;
export type Annotation = z.infer<typeof AnnotationSchema>;
export const PROVIDER_META: Record<Provider, ProviderMeta> = {
[Provider.OpenAI]: {
title: 'OpenAI',
link: 'https://openai.com',
description: 'GPT models, DALL-E, and Whisper from OpenAI',
placeholder: 'sk-...',
},
[Provider.Anthropic]: {
title: 'Anthropic',
link: 'https://anthropic.com',
description: 'Claude models from Anthropic',
placeholder: 'sk-ant-...',
},
[Provider.Gemini]: {
title: 'Google Gemini',
link: 'https://ai.google.dev/docs',
description: 'Gemini models from Google',
placeholder: 'AIza...',
},
[Provider.Mistral]: {
title: 'Mistral',
link: 'https://mistral.ai',
description: 'Mistral models and embeddings',
placeholder: 'mistral-...',
},
[Provider.Cohere]: {
title: 'Cohere',
link: 'https://cohere.com',
description: 'Command models and embeddings from Cohere',
placeholder: 'co_...',
},
[Provider.OpenRouter]: {
title: 'OpenRouter',
link: 'https://openrouter.ai',
description: 'Access to 300+ models through OpenRouter',
placeholder: 'sk-or-...',
},
models?: string[];
placeholder?: string;
};

View file

@ -1,40 +0,0 @@
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
export type AttachmentType = 'image' | 'video' | 'audio' | 'document';
export interface ProcessedAttachment {
type: AttachmentType;
url: string;
storage_id: string;
fileName: string;
mimeType: string;
size: number;
}
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
export function getSupportedAttachmentTypes(model: ModelInfo): AttachmentType[] {
const types: AttachmentType[] = [];
if (model.capabilities.vision) types.push('image');
if (model.capabilities.video) types.push('video');
if (model.capabilities.audio) types.push('audio');
if (model.capabilities.documents) types.push('document');
return types;
}
export function getFileType(file: File): AttachmentType | null {
const { type } = file;
if (type.startsWith('image/')) return 'image';
if (type.startsWith('video/')) return 'video';
if (type.startsWith('audio/')) return 'audio';
if (type === 'application/pdf' || type.startsWith('text/')) return 'document';
return null;
}
export function getAcceptString(types: AttachmentType[]): string {
const mimeTypes = types.map(type => `${type}/*`);
if (types.includes('document')) {
mimeTypes.push('application/pdf', 'text/*');
}
return mimeTypes.join(',');
}

View file

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

View file

@ -1,36 +0,0 @@
/**
* Utility functions for file operations and formatting
*/
/**
* Format file size in bytes to human readable string
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
/**
* Format time in seconds to MM:SS format
*/
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Open URL in new tab
*/
export function openInNewTab(url: string): void {
window.open(url, '_blank');
}
/**
* Get file extension from filename
*/
export function getFileExtension(fileName: string): string {
return fileName.split('.').pop()?.toLowerCase() || '';
}

View file

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

View file

@ -1,18 +1,9 @@
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import type { OpenRouterModel } from '$lib/backend/models/open-router';
export function supportsImages(model: ModelInfo): boolean {
return model.capabilities.vision;
export function supportsImages(model: OpenRouterModel): boolean {
return model.architecture.input_modalities.includes('image');
}
export function supportsReasoning(model: ModelInfo): boolean {
return model.capabilities.reasoning;
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
return models.filter(supportsImages);
}
export function supportsStreaming(model: ModelInfo): boolean {
return model.capabilities.streaming;
}
export function supportsToolCalls(model: ModelInfo): boolean {
return model.capabilities.functionCalling;
}

View file

@ -1,61 +1,34 @@
import { Result, ResultAsync } from 'neverthrow';
import { Provider, PROVIDER_META } from '$lib/types';
export type ProviderApiKeyData = {
export type OpenRouterApiKeyData = {
label: string;
usage?: number;
is_free_tier?: boolean;
is_provisioning_key?: boolean;
limit?: number;
limit_remaining?: number;
valid: boolean;
usage: number;
is_free_tier: boolean;
is_provisioning_key: boolean;
limit: number;
limit_remaining: number;
};
export const ProviderUtils = {
/**
* Validate an API key for a specific provider via server endpoint
*/
validateApiKey: async (provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> => {
export const OpenRouter = {
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => {
return await ResultAsync.fromPromise(
(async () => {
const response = await fetch('/api/validate-key', {
method: 'POST',
const res = await fetch('https://openrouter.ai/api/v1/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ provider, key }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
if (!res.ok) throw new Error('Failed to get API key');
const result = await response.json();
return result.data;
const { data } = await res.json();
if (!data) throw new Error('No info returned for api key');
return data as OpenRouterApiKeyData;
})(),
(e) => `Failed to validate API key: ${e}`
(e) => `Failed to get API key ${e}`
);
},
/**
* Get provider metadata
*/
getProviderMeta: (provider: Provider) => {
return PROVIDER_META[provider];
},
/**
* Check if a provider is supported
*/
isProviderSupported: (provider: string): provider is Provider => {
return Object.values(Provider).includes(provider as Provider);
},
/**
* Get all supported providers
*/
getSupportedProviders: () => {
return Object.values(Provider);
},
};

View file

@ -1,19 +1,17 @@
import { loadUserModels, loadGuestModels } from '$lib/services/model-loader.server';
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
import { Provider } from '$lib/types';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const session = await locals.auth();
// Load models based on user's API keys
const models = session?.session?.token
? await loadUserModels(session.session.token)
: loadGuestModels();
const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]);
return {
session,
models,
models: {
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
},
};
};
// Enable SSR for better performance
// Makes caching easier, and tbf, we don't need SSR anyways here
export const ssr = true;

View file

@ -10,19 +10,11 @@
import { browser } from '$app/environment';
import { MetaTags } from 'svelte-meta-tags';
import { page } from '$app/state';
import { setupLastChat } from '$lib/state/last-chat.svelte';
let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
const lastChat = setupLastChat();
models.init();
$effect(() => {
if (page.url.pathname.startsWith('/chat')) {
lastChat.current = page.params?.id ?? null;
}
});
</script>
<MetaTags

View file

@ -12,7 +12,6 @@
import { session } from '$lib/state/session.svelte.js';
import { api } from '$lib/backend/convex/_generated/api.js';
import { cn } from '$lib/utils/utils.js';
import { useLastChat } from '$lib/state/last-chat.svelte.js';
let { data, children } = $props();
@ -57,14 +56,6 @@
name: 'Search Messages',
keys: [cmdOrCtrl, 'K'],
},
{
name: 'Scroll to bottom',
keys: [cmdOrCtrl, 'D'],
},
{
name: 'Open Model Picker',
keys: [cmdOrCtrl, 'Shift', 'M'],
},
];
async function signOut() {
@ -72,15 +63,11 @@
await goto('/login');
}
const lastChat = useLastChat();
const backToChat = $derived(lastChat.current ? `/chat/${lastChat.current}` : '/chat');
</script>
<div class="container mx-auto max-w-[1200px] space-y-8 pt-6 pb-24">
<header class="flex place-items-center justify-between px-4">
<Button href={backToChat} variant="ghost" class="flex place-items-center gap-2 text-sm">
<Button href="/chat" variant="ghost" class="flex place-items-center gap-2 text-sm">
<ArrowLeftIcon class="size-4" />
Back to Chat
</Button>

View file

@ -1,8 +1,45 @@
<script lang="ts">
import { Provider, PROVIDER_META } from '$lib/types';
import { Provider, type ProviderMeta } from '$lib/types';
import ProviderCard from './provider-card.svelte';
const allProviders = Object.values(Provider);
const providersMeta: Record<Provider, ProviderMeta> = {
[Provider.OpenRouter]: {
title: 'OpenRouter',
link: 'https://openrouter.ai/settings/keys',
description: 'API Key for OpenRouter.',
models: ['a shit ton'],
placeholder: 'sk-or-...',
},
[Provider.HuggingFace]: {
title: 'HuggingFace',
link: 'https://huggingface.co/settings/tokens',
description: 'API Key for HuggingFace, for open-source models.',
placeholder: 'hf_...',
},
[Provider.OpenAI]: {
title: 'OpenAI',
link: 'https://platform.openai.com/account/api-keys',
description: 'API Key for OpenAI.',
models: ['gpt-3.5-turbo', 'gpt-4'],
placeholder: 'sk-...',
},
[Provider.Anthropic]: {
title: 'Anthropic',
link: 'https://console.anthropic.com/account/api-keys',
description: 'API Key for Anthropic.',
models: [
'Claude 3.5 Sonnet',
'Claude 3.7 Sonnet',
'Claude 3.7 Sonnet (Reasoning)',
'Claude 4 Opus',
'Claude 4 Sonnet',
'Claude 4 Sonnet (Reasoning)',
],
placeholder: 'sk-ant-...',
},
};
</script>
<svelte:head>
@ -12,13 +49,17 @@
<div>
<h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
Bring your own API keys for select models. Messages sent using your API keys will not count
towards your monthly limits.
</h2>
</div>
<div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)}
{@const meta = PROVIDER_META[provider]}
<!-- only do OpenRouter for now -->
{#if provider === Provider.OpenRouter}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
{/if}
{/each}
</div>

View file

@ -12,7 +12,7 @@
import { useConvexClient } from 'convex-svelte';
import { ResultAsync } from 'neverthrow';
import { resource } from 'runed';
import { ProviderUtils } from '$lib/utils/providers';
import * as providers from '$lib/utils/providers';
type Props = {
provider: Provider;
@ -65,8 +65,11 @@
async (key) => {
if (!key) return null;
const result = await ProviderUtils.validateApiKey(provider, key);
return result.unwrapOr(null);
if (provider === Provider.OpenRouter) {
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null);
}
return null;
}
);
</script>
@ -96,17 +99,11 @@
{#if apiKeyInfoResource.loading}
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
{:else if apiKeyInfoResource.current}
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined && apiKeyInfoResource.current.usage !== null && apiKeyInfoResource.current.limit_remaining !== null}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed(
3
)} remaining
</span>
{:else}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
✅ API key is valid
</span>
{/if}
{:else}
<span
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500"

View file

@ -5,86 +5,65 @@
import { Search } from '$lib/components/ui/search';
import { models } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte';
import { page } from '$app/state';
import { Provider, PROVIDER_META } from '$lib/types.js';
import { Provider } from '$lib/types.js';
import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { cn } from '$lib/utils/utils';
import { Toggle } from 'melt/builders';
import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x';
import ModelCard from './model-card.svelte';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
// Get all user's API keys to determine which providers are available
const userKeysQuery = useCachedQuery(api.user_keys.all, {
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '',
});
// Show providers that have API keys, regardless of whether models are loaded yet
const availableProviders = $derived.by(() => {
if (!userKeysQuery.data) {
return [];
}
return Object.entries(userKeysQuery.data)
.filter(([_, key]) => key) // Only providers with API keys
.map(([provider, _]) => provider as Provider);
});
const hasOpenRouterKey = $derived(
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== ''
);
let search = $state('');
let selectedProvider = $state<Provider | 'all'>('all');
// Filter toggles
const reasoningModelsToggle = new Toggle({
value: false,
const openRouterToggle = new Toggle({
value: true,
// TODO: enable this if and when when we use multiple providers
disabled: true,
});
const imageModelsToggle = new Toggle({
const freeModelsToggle = new Toggle({
value: false,
disabled: false,
});
const streamingModelsToggle = new Toggle({
value: false,
let initiallyEnabled = $state<string[]>([]);
$effect(() => {
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) {
initiallyEnabled = models
.from(Provider.OpenRouter)
.filter((m) => m.enabled)
.map((m) => m.id);
}
});
// Get models based on current filters
const filteredModels = $derived.by(() => {
let modelList = selectedProvider === 'all'
? models.all()
: models.from(selectedProvider);
const openRouterModels = $derived(
fuzzysearch({
haystack: models.from(Provider.OpenRouter).filter((m) => {
if (!freeModelsToggle.value) return true;
// Apply capability filters
if (reasoningModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.reasoning);
}
if (m.pricing.prompt === '0') return true;
if (imageModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.vision);
}
if (streamingModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.streaming);
}
// Apply text search
if (search) {
modelList = fuzzysearch({
haystack: modelList,
return false;
}),
needle: search,
property: 'name',
});
}
// Sort: enabled first, then by name
return modelList.sort((a, b) => {
if (a.enabled && !b.enabled) return -1;
if (!a.enabled && b.enabled) return 1;
return a.name.localeCompare(b.name);
});
});
const hasAnyApiKeys = $derived(availableProviders.length > 0);
}).sort((a, b) => {
const aEnabled = initiallyEnabled.includes(a.id);
const bEnabled = initiallyEnabled.includes(b.id);
if (aEnabled && !bEnabled) return -1;
if (!aEnabled && bEnabled) return 1;
return 0;
})
);
</script>
<svelte:head>
@ -96,125 +75,58 @@
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
{#if !hasAnyApiKeys}
<div class="mt-8 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<h3 class="font-semibold text-yellow-800">No API Keys Configured</h3>
<p class="text-sm text-yellow-700 mt-1">
You need to add API keys for at least one provider to see and manage models.
<a href="/account/api-keys" class="underline hover:text-yellow-900">Go to API Keys Settings</a>
</p>
</div>
{:else}
<div class="mt-6 space-y-4">
<!-- Search -->
<div class="mt-4 flex flex-col gap-2">
<Search bind:value={search} placeholder="Search models" />
<!-- Provider and filter tabs -->
<div class="flex flex-wrap items-center gap-2">
<!-- Provider selector -->
<div class="flex items-center gap-1">
<div class="flex place-items-center gap-2">
<button
onclick={() => selectedProvider = 'all'}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
selectedProvider === 'all'
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
{...openRouterToggle.trigger}
aria-label="OpenRouter"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
All Providers ({models.all().length})
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
{#each availableProviders as provider}
{@const providerMeta = PROVIDER_META[provider]}
{@const providerModels = models.from(provider)}
{@const hasModels = models.hasProvider(provider)}
{#if providerMeta}
<button
onclick={() => selectedProvider = provider}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
selectedProvider === provider
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
{...freeModelsToggle.trigger}
aria-label="Free Models"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
{providerMeta.title} ({hasModels ? providerModels.length : 'loading...'})
</button>
{/if}
{/each}
</div>
<!-- Capability filters -->
<div class="h-4 w-px bg-border"></div>
<button
{...reasoningModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
reasoningModelsToggle.value
? "bg-blue-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
Reasoning
</button>
<button
{...imageModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
imageModelsToggle.value
? "bg-green-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
Vision
</button>
<button
{...streamingModelsToggle.trigger}
class={cn(
"px-3 py-1 rounded-full text-sm transition-all",
streamingModelsToggle.value
? "bg-purple-500 text-white"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
Streaming
Free
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
</div>
</div>
<!-- Models grid -->
{#if filteredModels.length === 0}
<div class="text-center py-12">
{#if selectedProvider !== 'all' && !models.hasProvider(selectedProvider)}
<h3 class="text-lg font-semibold text-muted-foreground">Loading models...</h3>
<p class="text-sm text-muted-foreground mt-2">
Models are being loaded from {PROVIDER_META[selectedProvider]?.title || selectedProvider}. Please refresh the page in a moment.
</p>
{:else}
<h3 class="text-lg font-semibold text-muted-foreground">No models found</h3>
<p class="text-sm text-muted-foreground mt-2">
{#if search}
Try adjusting your search or filters.
{:else}
No models match your current filters.
{/if}
</p>
{/if}
{#if openRouterModels.length > 0}
<div class="mt-4 flex flex-col gap-4">
<div>
<h3 class="text-lg font-bold">OpenRouter</h3>
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p>
</div>
{:else}
<div class="grid gap-4">
{#each filteredModels as model (model.id)}
<div class="relative">
<div
class={cn('flex flex-col gap-4 overflow-hidden', {
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
})}
>
{#each openRouterModels as model (model.id)}
<ModelCard
provider={model.provider as Provider}
provider={Provider.OpenRouter}
{model}
enabled={model.enabled}
disabled={false}
disabled={!hasOpenRouterKey}
/>
{/each}
</div>
{#if !hasOpenRouterKey}
<div
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
>
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import type { Provider } from '$lib/types';
import { PROVIDER_META } from '$lib/types';
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import { useConvexClient } from 'convex-svelte';
@ -8,16 +7,16 @@
import { session } from '$lib/state/session.svelte.js';
import { ResultAsync } from 'neverthrow';
import { getFirstSentence } from '$lib/utils/strings';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import Tooltip from '$lib/components/ui/tooltip.svelte';
import EyeIcon from '~icons/lucide/eye';
import BrainIcon from '~icons/lucide/brain';
import ZapIcon from '~icons/lucide/zap';
import CpuIcon from '~icons/lucide/cpu';
type Model = {
id: string;
name: string;
description: string;
};
type Props = {
provider: Provider;
model: ModelInfo;
model: Model;
enabled?: boolean;
disabled?: boolean;
};
@ -25,23 +24,15 @@
let { provider, model, enabled = false, disabled = false }: Props = $props();
const client = useConvexClient();
const providerMeta = $derived(PROVIDER_META[provider]);
const [shortDescription, fullDescription] = $derived(
model.description ? getFirstSentence(model.description) : [null, model.name]
);
const [shortDescription, fullDescription] = $derived(getFirstSentence(model.description));
let showMore = $state(false);
async function toggleEnabled(v: boolean) {
console.log('toggleEnabled called:', { provider, model_id: model.id, enabled: v });
enabled = v; // Optimistic!
if (!session.current?.user.id) {
console.log('No user session, returning early');
return;
}
if (!session.current?.user.id) return;
console.log('Calling Convex mutation...');
const res = await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.set, {
provider,
@ -52,138 +43,31 @@
(e) => e
);
if (res.isErr()) {
console.error('Mutation failed:', res.error);
enabled = !v; // Revert on error
} else {
console.log('Mutation succeeded');
if (res.isErr()) enabled = !v; // Should have been a realist :(
}
}
// Format pricing information
const pricingInfo = $derived.by(() => {
if (!model.pricing) return null;
const { inputTokens, outputTokens } = model.pricing;
const inputPrice = inputTokens < 1 ? `$${(inputTokens * 1000).toFixed(3)}/1K` : `$${inputTokens.toFixed(3)}/1M`;
const outputPrice = outputTokens < 1 ? `$${(outputTokens * 1000).toFixed(3)}/1K` : `$${outputTokens.toFixed(3)}/1M`;
return `${inputPrice} → ${outputPrice}`;
});
// Format context window
const contextInfo = $derived.by(() => {
const contextWindow = model.contextWindow;
if (contextWindow >= 1000000) {
return `${(contextWindow / 1000000).toFixed(1)}M context`;
} else if (contextWindow >= 1000) {
return `${(contextWindow / 1000).toFixed(0)}K context`;
} else {
return `${contextWindow} tokens`;
}
});
</script>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title>
<span class="px-2 py-0.5 text-xs rounded-full bg-secondary text-secondary-foreground">
{providerMeta.title}
</span>
<span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span>
</div>
<span class="text-muted-foreground text-xs">{model.id}</span>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div>
<Switch bind:value={enabled} onValueChange={toggleEnabled} {disabled} />
</div>
{#if model.description}
<Card.Description>
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
</Card.Description>
<Card.Description
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
>
{#if shortDescription !== null}
<button
type="button"
class="text-muted-foreground w-fit text-start text-xs hover:text-foreground transition-colors"
class="text-muted-foreground w-fit text-start text-xs"
onclick={() => (showMore = !showMore)}
{disabled}
>
{showMore ? 'Show less' : 'Show more'}
</button>
{/if}
{/if}
</Card.Header>
<Card.Content>
<div class="flex items-center justify-between">
<!-- Capabilities badges -->
<div class="flex place-items-center gap-1">
{#if model.capabilities.vision}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
>
<EyeIcon class="size-3" />
</div>
{/snippet}
Supports vision/image analysis
</Tooltip>
{/if}
{#if model.capabilities.reasoning}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
>
<BrainIcon class="size-3" />
</div>
{/snippet}
Supports reasoning
</Tooltip>
{/if}
{#if model.capabilities.streaming}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
>
<ZapIcon class="size-3" />
</div>
{/snippet}
Supports streaming responses
</Tooltip>
{/if}
{#if model.capabilities.functionCalling}
<Tooltip>
{#snippet trigger(tooltip)}
<div
{...tooltip.trigger}
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
>
<CpuIcon class="size-3" />
</div>
{/snippet}
Supports tool/function calling
</Tooltip>
{/if}
</div>
<!-- Model info -->
<div class="flex items-center gap-3 text-xs text-muted-foreground">
{#if pricingInfo}
<span>{pricingInfo}</span>
{/if}
<span>{contextInfo}</span>
</div>
</div>
</Card.Content>
</Card.Root>

View file

@ -1,12 +1,15 @@
import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ResultAsync } from 'neverthrow';
import { z } from 'zod/v4';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { OpenAI } from 'openai';
import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api';
import { parseMessageForRules } from '$lib/utils/rules';
import { createModelManager } from '$lib/services/model-manager';
import type { UserApiKeys } from '$lib/services/model-manager';
import { Provider } from '$lib/types';
const FREE_MODEL = 'google/gemma-3-27b-it';
const reqBodySchema = z.object({
prompt: z.string(),
@ -28,29 +31,6 @@ function response({ enhanced_prompt }: { enhanced_prompt: string }) {
});
}
async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
return null;
}
const keys = keysResult.value;
return {
openai: keys.openai,
anthropic: keys.anthropic,
gemini: keys.google,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
};
}
export const POST: RequestHandler = async ({ request, locals }) => {
const bodyResult = await ResultAsync.fromPromise(
request.json(),
@ -73,60 +53,39 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return error(401, 'You must be logged in to enhance a prompt');
}
// Get user API keys
const userApiKeys = await getUserApiKeys(session.session.token);
if (!userApiKeys) {
return error(500, 'Failed to get user API keys');
}
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
if (!hasAnyKey) {
return error(
400,
'No API keys configured. Please add at least one provider API key in settings to enhance prompts.'
);
}
// Get user rules for context
const rulesResult = await ResultAsync.fromPromise(
const [rulesResult, keyResult] = await Promise.all([
ResultAsync.fromPromise(
client.query(api.user_rules.all, {
session_token: session.session.token,
}),
(e) => `Failed to get rules: ${e}`
);
),
ResultAsync.fromPromise(
client.query(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.session.token,
}),
(e) => `Failed to get API key: ${e}`
),
]);
if (rulesResult.isErr()) {
return error(500, 'Failed to get rules');
}
if (keyResult.isErr()) {
return error(500, 'Failed to get key');
}
const mentionedRules = parseMessageForRules(
args.prompt,
rulesResult.value.filter((r) => r.attach === 'manual')
);
// Initialize model manager with user's API keys
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
// Try to find a fast, cheap model for prompt enhancement
const availableModels = await modelManager.listAvailableModels();
const enhanceModel =
availableModels.find(
(model) =>
model.id.includes('kimi-k2') ||
model.id.includes('gemini-2.5-flash-lite') ||
model.id.includes('gpt-5-mini') ||
model.id.includes('mistral-small')
) || availableModels[0];
if (!enhanceModel) {
return error(500, 'No suitable models available for prompt enhancement');
}
const provider = modelManager.getProvider(enhanceModel.provider);
if (!provider) {
return error(500, `Provider ${enhanceModel.provider} not available`);
}
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY,
});
const enhancePrompt = `
Enhance prompt below (wrapped in <prompt> tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM.
@ -148,24 +107,23 @@ ${args.prompt}
`;
const enhancedResult = await ResultAsync.fromPromise(
provider.generateCompletion({
model: enhanceModel.id,
openai.chat.completions.create({
model: FREE_MODEL,
messages: [{ role: 'user', content: enhancePrompt }],
temperature: 0.5,
maxTokens: 1000,
}),
(e) => `Enhance prompt API call failed: ${e}`
);
if (enhancedResult.isErr()) {
return error(500, 'Error enhancing the prompt');
return error(500, 'error enhancing the prompt');
}
const enhancedResponse = enhancedResult.value;
const enhanced = enhancedResponse.content?.trim();
const enhanced = enhancedResponse.choices[0]?.message?.content;
if (!enhanced) {
return error(500, 'Error enhancing the prompt');
return error(500, 'error enhancing the prompt');
}
return response({

View file

@ -1,19 +1,19 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { Provider, type Annotation } from '$lib/types';
import { Provider } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit';
import { waitUntil } from '@vercel/functions';
import { getSessionCookie } from 'better-auth/cookies';
import { ConvexHttpClient } from 'convex/browser';
import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js';
import * as array from '$lib/utils/array';
import { parseMessageForRules } from '$lib/utils/rules.js';
import { createModelManager, type ChatModelManager } from '$lib/services/model-manager.js';
import type { UserApiKeys } from '$lib/services/model-manager.js';
// Set to true to enable debug logging
const ENABLE_LOGGING = true;
@ -22,26 +22,24 @@ const reqBodySchema = z
.object({
message: z.string().optional(),
model_id: z.string(),
session_token: z.string(),
conversation_id: z.string().optional(),
web_search_enabled: z.boolean().optional(),
attachments: z
images: z
.array(
z.object({
type: z.enum(['image', 'video', 'audio', 'document']),
url: z.string(),
storage_id: z.string(),
fileName: z.string(),
mimeType: z.string(),
size: z.number(),
fileName: z.string().optional(),
})
)
.optional(),
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
})
.refine(
(data) => {
if (data.conversation_id === undefined && data.message === undefined) return false;
return true;
},
{
@ -68,45 +66,34 @@ function log(message: string, startTime: number): void {
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
async function getUserApiKeys(sessionToken: string): Promise<Result<UserApiKeys, string>> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
return err(keysResult.error);
}
const keys = keysResult.value;
return ok({
openai: keys.openai,
anthropic: keys.anthropic,
google: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
});
}
async function generateConversationTitle({
conversationId,
sessionToken,
startTime,
keyResultPromise,
userMessage,
modelManager,
}: {
conversationId: string;
sessionToken: string;
startTime: number;
keyResultPromise: ResultAsync<string | null, string>;
userMessage: string;
modelManager: ChatModelManager;
}) {
log('Starting conversation title generation', startTime);
// Check if conversation currently has default title
const keyResult = await keyResultPromise;
if (keyResult.isErr()) {
log(`Title generation: API key error: ${keyResult.error}`, startTime);
return;
}
const userKey = keyResult.value;
const actualKey = userKey || OPENROUTER_FREE_KEY;
log(`Title generation: Using ${userKey ? 'user' : 'free tier'} API key`, startTime);
// Only generate title if conversation currently has default title
const conversationResult = await ResultAsync.fromPromise(
client.query(api.conversations.get, {
session_token: sessionToken,
@ -127,25 +114,12 @@ async function generateConversationTitle({
return;
}
// Try to find a fast, cheap model for title generation
const availableModels = await modelManager.listAvailableModels();
const titleModel =
availableModels.find((model) => model.id.includes('gemini-2.5-flash-lite')) ||
availableModels.find((model) => model.id.includes('kimi-k2')) ||
availableModels.find((model) => model.id.includes('gpt-5-mini')) ||
availableModels[0];
if (!titleModel) {
log('Title generation: No suitable model available', startTime);
return;
}
const provider = modelManager.getProvider(titleModel.provider);
if (!provider) {
log(`Title generation: Provider ${titleModel.provider} not found`, startTime);
return;
}
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
// Create a prompt for title generation using only the first user message
const titlePrompt = `Based on this message:
"""${userMessage}"""
@ -154,25 +128,26 @@ Generate only the title based on the message, nothing else. Don't name the title
Also, do not interact with the message directly or answer it. Just generate the title based on the message.
If its a simple hi, just name it "Greeting" or something like that.`;
If its a simple hi, just name it "Greeting" or something like that.
`;
const titleResult = await ResultAsync.fromPromise(
provider.generateCompletion({
model: titleModel.id,
openai.chat.completions.create({
model: 'mistralai/ministral-8b',
messages: [{ role: 'user', content: titlePrompt }],
maxTokens: 1024,
max_tokens: 20,
temperature: 0.5,
}),
(e) => `Title generation API call failed: ${e}`
);
if (titleResult.isErr()) {
log(`Title generation: API call failed: ${titleResult.error}`, startTime);
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime);
return;
}
const titleResponse = titleResult.value;
const rawTitle = titleResponse.content?.trim();
const rawTitle = titleResponse.choices[0]?.message?.content?.trim();
if (!rawTitle) {
log('Title generation: No title generated', startTime);
@ -204,20 +179,20 @@ async function generateAIResponse({
conversationId,
sessionToken,
startTime,
modelId,
modelManager,
modelResultPromise,
keyResultPromise,
rulesResultPromise,
userSettingsPromise,
abortSignal,
reasoningEffort,
}: {
conversationId: string;
sessionToken: string;
startTime: number;
modelId: string;
modelManager: ChatModelManager;
keyResultPromise: ResultAsync<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal;
reasoningEffort?: 'low' | 'medium' | 'high';
}) {
log('Starting AI response generation in background', startTime);
@ -226,34 +201,10 @@ async function generateAIResponse({
return;
}
// Get model and provider
const model = await modelManager.getModel(modelId);
if (!model) {
handleGenerationError({
error: `Model ${modelId} not found or not available`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const provider = modelManager.getProvider(model.provider);
if (!provider) {
handleGenerationError({
error: `Provider ${model.provider} not available`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
log(`Background: Using model ${modelId} with provider ${model.provider}`, startTime);
const [messagesQueryResult, rulesResult] = await Promise.all([
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] =
await Promise.all([
modelResultPromise,
keyResultPromise,
ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
@ -262,8 +213,34 @@ async function generateAIResponse({
(e) => `Failed to get messages: ${e}`
),
rulesResultPromise,
userSettingsPromise,
]);
if (modelResult.isErr()) {
handleGenerationError({
error: modelResult.error,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const model = modelResult.value;
if (!model) {
handleGenerationError({
error: 'Model not found or not enabled',
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
log('Background: Model found and enabled', startTime);
if (messagesQueryResult.isErr()) {
handleGenerationError({
error: `messages query failed: ${messagesQueryResult.error}`,
@ -282,14 +259,14 @@ async function generateAIResponse({
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
const finalModelId = webSearchEnabled ? `${modelId}:online` : modelId;
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id;
// Create assistant message
const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, {
conversation_id: conversationId,
model_id: modelId,
provider: model.provider as Provider,
model_id: model.model_id,
provider: Provider.OpenRouter,
content: '',
role: 'assistant',
session_token: sessionToken,
@ -312,6 +289,84 @@ async function generateAIResponse({
const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime);
if (keyResult.isErr()) {
handleGenerationError({
error: `API key query failed: ${keyResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
if (userSettingsResult.isErr()) {
handleGenerationError({
error: `User settings query failed: ${userSettingsResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
const userKey = keyResult.value;
const userSettings = userSettingsResult.value;
let actualKey: string;
if (userKey) {
// User has their own API key
actualKey = userKey;
log('Background: Using user API key', startTime);
} else {
// User doesn't have API key, check if using a free model
const isFreeModel = model.model_id.endsWith(':free');
if (!isFreeModel) {
// For non-free models, check the 10 message limit
const freeMessagesUsed = userSettings?.free_messages_used || 0;
if (freeMessagesUsed >= 10) {
handleGenerationError({
error:
'Free message limit reached (10/10). Please add your own OpenRouter API key to continue chatting, or use a free model ending in ":free".',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
// Increment free message count before generating (only for non-free models)
const incrementResult = await ResultAsync.fromPromise(
client.mutation(api.user_settings.incrementFreeMessageCount, {
session_token: sessionToken,
}),
(e) => `Failed to increment free message count: ${e}`
);
if (incrementResult.isErr()) {
handleGenerationError({
error: `Failed to track free message usage: ${incrementResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
log(`Background: Using free tier (${freeMessagesUsed + 1}/10 messages)`, startTime);
} else {
log(`Background: Using free model (${model.model_id}) - no message count`, startTime);
}
// Use environment OpenRouter key
actualKey = OPENROUTER_FREE_KEY;
}
if (rulesResult.isErr()) {
handleGenerationError({
error: `rules query failed: ${rulesResult.error}`,
@ -347,7 +402,7 @@ async function generateAIResponse({
attachedRules.push(...parsedRules);
}
// Remove duplicates
// remove duplicates
attachedRules = array.fromMap(
array.toMap(attachedRules, (r) => [r._id, r]),
(_k, v) => v
@ -355,20 +410,24 @@ async function generateAIResponse({
log(`Background: ${attachedRules.length} rules attached`, startTime);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
const formattedMessages = messages.map((m) => {
if (m.attachments && m.attachments.length > 0 && m.role === 'user') {
const contentParts = [
{ type: 'text', text: m.content },
...m.attachments.map(attachment => ({
type: attachment.type,
[`${attachment.type}Url`]: attachment.url,
mimeType: attachment.mimeType,
}))
];
return { role: 'user' as const, content: contentParts };
if (m.images && m.images.length > 0 && m.role === 'user') {
return {
role: 'user' as const,
content: [
{ type: 'text' as const, text: m.content },
...m.images.map((img) => ({
type: 'image_url' as const,
image_url: { url: img.url },
})),
],
};
}
return {
role: m.role as 'user' | 'assistant' | 'system',
content: m.content,
@ -400,19 +459,24 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
// Generate streaming completion
let stream: AsyncIterable<any>;
try {
stream = provider.streamCompletion({
model: finalModelId,
const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create(
{
model: modelId,
messages: messagesToSend,
temperature: 0.7,
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
});
log('Background: Stream created successfully', startTime);
} catch (error) {
stream: true,
},
{
signal: abortSignal,
}
),
(e) => `OpenAI API call failed: ${e}`
);
if (streamResult.isErr()) {
handleGenerationError({
error: `Failed to create stream: API call failed: ${error}`,
error: `Failed to create stream: ${streamResult.error}`,
conversationId,
messageId: mid,
sessionToken,
@ -421,14 +485,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime);
let content = '';
let reasoning = '';
let chunkCount = 0;
let generationId: string | null = null;
const annotations: Annotation[] = [];
try {
// Handle streaming response
for await (const chunk of stream) {
if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime);
@ -436,30 +500,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}
chunkCount++;
content += chunk.choices[0]?.delta?.content || '';
if (!content) continue;
// Extract content from chunk based on the kepler-ai-sdk format
if (chunk && typeof chunk === 'object') {
const chunkContent = chunk.delta || chunk.content || chunk.text || '';
const chunkReasoning = chunk.reasoning || '';
const chunkAnnotations = chunk.annotations || [];
reasoning += chunkReasoning;
content += chunkContent;
annotations.push(...chunkAnnotations);
if (!chunkContent && !chunkReasoning) continue;
generationId = chunk.id || generationId;
generationId = chunk.id;
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
);
@ -471,27 +521,56 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
);
}
}
}
log(
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
startTime
);
// Final message update with completion stats
if (!generationId) {
log('Background: No generation id found', startTime);
return;
}
const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content),
(e) => `Failed to render HTML: ${e}`
);
const generationStatsResult = await retryResult(
() => getGenerationStats(generationId!, actualKey),
{
delay: 500,
retries: 2,
startTime,
fnName: 'getGenerationStats',
}
);
if (generationStatsResult.isErr()) {
log(`Background: Failed to get generation stats: ${generationStatsResult.error}`, startTime);
}
// just default so we don't blow up
const generationStats = generationStatsResult.unwrapOr({
tokens_completion: undefined,
total_cost: undefined,
});
log('Background: Got generation stats', startTime);
const contentHtmlResult = await contentHtmlResultPromise;
const [updateMessageResult, updateGeneratingResult] = await Promise.all([
if (contentHtmlResult.isErr()) {
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, {
message_id: mid,
token_count: undefined, // Will be calculated by provider if available
cost_usd: undefined, // Will be calculated by provider if available
token_count: generationStats.tokens_completion,
cost_usd: generationStats.total_cost,
generation_id: generationId,
session_token: sessionToken,
content_html: contentHtmlResult.unwrapOr(undefined),
@ -506,6 +585,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}),
(e) => `Failed to update generating status: ${e}`
),
ResultAsync.fromPromise(
client.mutation(api.conversations.updateCostUsd, {
conversation_id: conversationId as Id<'conversations'>,
cost_usd: generationStats.total_cost ?? 0,
session_token: sessionToken,
}),
(e) => `Failed to update cost usd: ${e}`
),
]);
if (updateGeneratingResult.isErr()) {
@ -521,6 +608,13 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}
log('Background: Message updated', startTime);
if (updateCostUsdResult.isErr()) {
log(`Background cost usd update failed: ${updateCostUsdResult.error}`, startTime);
return;
}
log('Background: Cost usd updated', startTime);
} catch (error) {
handleGenerationError({
error: `Stream processing error: ${error}`,
@ -562,6 +656,7 @@ export const POST: RequestHandler = async ({ request }) => {
log('Schema validation passed', startTime);
const cookie = getSessionCookie(request.headers);
const sessionToken = cookie?.split('.')[0] ?? null;
if (!sessionToken) {
@ -569,37 +664,29 @@ export const POST: RequestHandler = async ({ request }) => {
return error(401, 'Unauthorized');
}
// Get user API keys
const userApiKeysResult = await getUserApiKeys(sessionToken);
if (userApiKeysResult.isErr()) {
log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
return error(500, 'Failed to get user API keys');
}
const userApiKeys = userApiKeysResult.value;
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
if (!hasAnyKey) {
log('User has no API keys configured', startTime);
return error(
400,
'No API keys configured. Please add at least one provider API key in settings.'
const modelResultPromise = ResultAsync.fromPromise(
client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter,
model_id: args.model_id,
session_token: sessionToken,
}),
(e) => `Failed to get model: ${e}`
);
}
// Initialize model manager with user's API keys
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
// Check if the requested model is available
const modelAvailable = await modelManager.isModelAvailable(args.model_id);
if (!modelAvailable) {
log(`Requested model ${args.model_id} not available`, startTime);
return error(
400,
`Model ${args.model_id} is not available. Please check your API keys and try a different model.`
const keyResultPromise = ResultAsync.fromPromise(
client.query(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: sessionToken,
}),
(e) => `Failed to get API key: ${e}`
);
const userSettingsPromise = ResultAsync.fromPromise(
client.query(api.user_settings.get, {
session_token: sessionToken,
}),
(e) => `Failed to get user settings: ${e}`
);
}
const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, {
@ -612,7 +699,7 @@ export const POST: RequestHandler = async ({ request }) => {
let conversationId = args.conversation_id;
if (!conversationId) {
// Create new conversation
// technically zod should catch this but just in case
if (args.message === undefined) {
return error(400, 'You must provide a message when creating a new conversation');
}
@ -622,7 +709,7 @@ export const POST: RequestHandler = async ({ request }) => {
content: args.message,
content_html: '',
role: 'user',
attachments: args.attachments,
images: args.images,
web_search_enabled: args.web_search_enabled,
session_token: sessionToken,
}),
@ -643,8 +730,8 @@ export const POST: RequestHandler = async ({ request }) => {
conversationId,
sessionToken,
startTime,
keyResultPromise,
userMessage: args.message,
modelManager,
}).catch((error) => {
log(`Background title generation error: ${error}`, startTime);
})
@ -659,9 +746,8 @@ export const POST: RequestHandler = async ({ request }) => {
content: args.message,
session_token: args.session_token,
model_id: args.model_id,
reasoning_effort: args.reasoning_effort,
role: 'user',
attachments: args.attachments,
images: args.images,
web_search_enabled: args.web_search_enabled,
}),
(e) => `Failed to create user message: ${e}`
@ -695,17 +781,17 @@ export const POST: RequestHandler = async ({ request }) => {
const abortController = new AbortController();
generationAbortControllers.set(conversationId, abortController);
// Start AI response generation in background
// Start AI response generation in background - don't await
waitUntil(
generateAIResponse({
conversationId,
sessionToken,
startTime,
modelId: args.model_id,
modelManager,
modelResultPromise,
keyResultPromise,
rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal,
reasoningEffort: args.reasoning_effort,
})
.catch(async (error) => {
log(`Background AI response generation error: ${error}`, startTime);
@ -730,6 +816,57 @@ export const POST: RequestHandler = async ({ request }) => {
return response({ ok: true, conversation_id: conversationId });
};
async function getGenerationStats(
generationId: string,
token: string
): Promise<Result<Data, string>> {
try {
const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { data } = await generation.json();
if (!data) {
return err('No data returned from OpenRouter');
}
return ok(data);
} catch {
return err('Failed to get generation stats');
}
}
async function retryResult<T, E>(
fn: () => Promise<Result<T, E>>,
{
retries,
delay,
startTime,
fnName,
}: { retries: number; delay: number; startTime: number; fnName: string }
): Promise<Result<T, E>> {
let attempts = 0;
let lastResult: Result<T, E> | null = null;
while (attempts <= retries) {
lastResult = await fn();
if (lastResult.isOk()) return lastResult;
log(`Retrying ${fnName} ${attempts} failed: ${lastResult.error}`, startTime);
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
if (!lastResult) throw new Error('This should never happen');
return lastResult;
}
async function handleGenerationError({
error,
conversationId,
@ -762,3 +899,38 @@ async function handleGenerationError({
log('Error updated', startTime);
}
export interface ApiResponse {
data: Data;
}
export interface Data {
created_at: string;
model: string;
app_id: string | null;
external_user: string | null;
streamed: boolean;
cancelled: boolean;
latency: number;
moderation_latency: number | null;
generation_time: number;
tokens_prompt: number;
tokens_completion: number;
native_tokens_prompt: number;
native_tokens_completion: number;
native_tokens_reasoning: number;
native_tokens_cached: number;
num_media_prompt: number | null;
num_media_completion: number | null;
num_search_results: number | null;
origin: string;
is_byok: boolean;
finish_reason: string;
native_finish_reason: string;
usage: number;
id: string;
upstream_id: string;
total_cost: number;
cache_discount: number | null;
provider_name: string;
}

View file

@ -1,218 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Provider } from '$lib/types';
import { Result, ResultAsync } from 'neverthrow';
export type ProviderApiKeyData = {
label: string;
usage?: number;
is_free_tier?: boolean;
is_provisioning_key?: boolean;
limit?: number;
limit_remaining?: number;
valid: boolean;
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { provider, key } = await request.json();
if (!provider || !key) {
return json({ error: 'Missing provider or key' }, { status: 400 });
}
if (!Object.values(Provider).includes(provider)) {
return json({ error: 'Invalid provider' }, { status: 400 });
}
const result = await validateApiKey(provider, key);
if (result.isErr()) {
return json({ error: result.error }, { status: 400 });
}
return json({ data: result.value });
} catch (error) {
console.error('API key validation error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
};
async function validateApiKey(provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> {
switch (provider) {
case Provider.OpenRouter:
return await validateOpenRouterKey(key);
case Provider.OpenAI:
return await validateOpenAIKey(key);
case Provider.Anthropic:
return await validateAnthropicKey(key);
case Provider.Gemini:
return await validateGeminiKey(key);
case Provider.Mistral:
return await validateMistralKey(key);
case Provider.Cohere:
return await validateCohereKey(key);
default:
return Result.err(`Validation not implemented for provider: ${provider}`);
}
}
// Provider-specific validation functions
async function validateOpenRouterKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const { data } = await res.json();
if (!data) {
throw new Error('No key information returned');
}
return {
label: data.label || 'OpenRouter API Key',
usage: data.usage,
is_free_tier: data.is_free_tier,
is_provisioning_key: data.is_provisioning_key,
limit: data.limit,
limit_remaining: data.limit_remaining,
valid: true,
};
})(),
(e) => `Failed to validate OpenRouter API key: ${e}`
);
}
async function validateOpenAIKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'OpenAI API Key',
valid: true,
};
})(),
(e) => `Failed to validate OpenAI API key: ${e}`
);
}
async function validateAnthropicKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
// Anthropic doesn't have a simple key validation endpoint
// We'll try a minimal request to test the key
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
// Even a 400 error means the key is valid (just bad request format)
if (res.status === 401 || res.status === 403) {
throw new Error('Invalid API key');
}
return {
label: 'Anthropic API Key',
valid: true,
};
})(),
(e) => `Failed to validate Anthropic API key: ${e}`
);
}
async function validateGeminiKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${key}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Google Gemini API Key',
valid: true,
};
})(),
(e) => `Failed to validate Gemini API key: ${e}`
);
}
async function validateMistralKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.mistral.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Mistral API Key',
valid: true,
};
})(),
(e) => `Failed to validate Mistral API key: ${e}`
);
}
async function validateCohereKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.cohere.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Cohere API Key',
valid: true,
};
})(),
(e) => `Failed to validate Cohere API key: ${e}`
);
}

View file

@ -20,8 +20,7 @@
import { settings } from '$lib/state/settings.svelte.js';
import { Provider } from '$lib/types';
import { compressImage } from '$lib/utils/image-compression';
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
import { getSupportedAttachmentTypes, getFileType, getAcceptString, type ProcessedAttachment } from '$lib/utils/attachment-manager';
import { supportsImages } from '$lib/utils/model-capabilities';
import { omit, pick } from '$lib/utils/object.js';
import { cn } from '$lib/utils/utils.js';
import { useConvexClient } from 'convex-svelte';
@ -31,10 +30,6 @@
import SendIcon from '~icons/lucide/arrow-up';
import ChevronDownIcon from '~icons/lucide/chevron-down';
import ImageIcon from '~icons/lucide/image';
import VideoIcon from '~icons/lucide/video';
import AudioIcon from '~icons/lucide/music';
import FileTextIcon from '~icons/lucide/file-text';
import PaperclipIcon from '~icons/lucide/paperclip';
import PanelLeftIcon from '~icons/lucide/panel-left';
import SearchIcon from '~icons/lucide/search';
import Settings2Icon from '~icons/lucide/settings-2';
@ -43,16 +38,13 @@
import XIcon from '~icons/lucide/x';
import { callCancelGeneration } from '../api/cancel-generation/call.js';
import { callGenerateMessage } from '../api/generate-message/call.js';
import { ModelPicker } from '$lib/components/model-picker';
import ModelPicker from './model-picker.svelte';
import SearchModal from './search-modal.svelte';
import { shortcut } from '$lib/actions/shortcut.svelte.js';
import { mergeAttrs } from 'melt';
import { callEnhancePrompt } from '../api/enhance-prompt/call.js';
import ShinyText from '$lib/components/animations/shiny-text.svelte';
import SparkleIcon from '~icons/lucide/sparkle';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import BrainIcon from '~icons/lucide/brain';
import * as casing from '$lib/utils/casing.js';
const client = useConvexClient();
@ -124,8 +116,8 @@
loading = true;
const attachmentsCopy = [...selectedAttachments];
selectedAttachments = [];
const imagesCopy = [...selectedImages];
selectedImages = [];
try {
const res = await callGenerateMessage({
@ -133,9 +125,8 @@
session_token: session.current?.session.token,
conversation_id: page.params.id ?? undefined,
model_id: settings.modelId,
attachments: attachmentsCopy.length > 0 ? attachmentsCopy : undefined,
images: imagesCopy.length > 0 ? imagesCopy : undefined,
web_search_enabled: settings.webSearchEnabled,
reasoning_effort: currentModelSupportsReasoning ? settings.reasoningEffort : undefined,
});
if (res.isErr()) {
@ -200,7 +191,7 @@
const autosize = new TextareaAutosize();
const message = new PersistedState('prompt', '');
let selectedAttachments = $state<ProcessedAttachment[]>([]);
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
let isUploading = $state(false);
let fileInput = $state<HTMLInputElement>();
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
@ -216,65 +207,45 @@
models.init();
const currentModel = $derived.by(() => {
if (!settings.modelId) return null;
return models.all().find((m) => m.id === settings.modelId) || null;
});
const supportedAttachmentTypes = $derived(
currentModel ? getSupportedAttachmentTypes(currentModel) : []
);
const currentModelSupportsReasoning = $derived.by(() => {
const currentModelSupportsImages = $derived.by(() => {
if (!settings.modelId) return false;
const allModels = models.all();
const currentModel = allModels.find((m) => m.id === settings.modelId);
if (!currentModel) return false;
return supportsReasoning(currentModel);
const openRouterModels = models.from(Provider.OpenRouter);
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
return currentModel ? supportsImages(currentModel) : false;
});
const fileUpload = new FileUpload({
multiple: true,
maxSize: 100 * 1024 * 1024, // 100MB max for any file type
});
// Update file input accept attribute reactively
$effect(() => {
if (fileInput) {
fileInput.accept = getAcceptString(supportedAttachmentTypes);
}
accept: 'image/*',
maxSize: 10 * 1024 * 1024, // 10MB
});
async function handleFileChange(files: File[]) {
if (!files.length || !session.current?.session.token || !currentModel) return;
if (!files.length || !session.current?.session.token) return;
isUploading = true;
const uploadedFiles: ProcessedAttachment[] = [];
const uploadedFiles: { url: string; storage_id: string; fileName?: string }[] = [];
try {
for (const file of files) {
// Simple file validation
const fileType = getFileType(file);
if (!fileType || !supportedAttachmentTypes.includes(fileType)) {
console.warn(`Unsupported file type: ${file.name}`);
// Skip non-image files
if (!file.type.startsWith('image/')) {
console.warn('Skipping non-image file:', file.name);
continue;
}
// Compress images for better performance
let fileToUpload = file;
if (fileType === 'image') {
fileToUpload = await compressImage(file, 1024 * 1024);
}
// Compress image to max 1MB
const compressedFile = await compressImage(file, 1024 * 1024);
// Generate upload URL
const uploadUrl = await client.mutation(api.storage.generateUploadUrl, {
session_token: session.current.session.token,
});
// Upload file
// Upload compressed file
const result = await fetch(uploadUrl, {
method: 'POST',
body: fileToUpload,
body: compressedFile,
});
if (!result.ok) {
@ -290,18 +261,11 @@
});
if (url) {
uploadedFiles.push({
type: fileType,
url,
storage_id: storageId,
fileName: file.name,
mimeType: file.type,
size: file.size
});
uploadedFiles.push({ url, storage_id: storageId, fileName: file.name });
}
}
selectedAttachments = [...selectedAttachments, ...uploadedFiles];
selectedImages = [...selectedImages, ...uploadedFiles];
} catch (error) {
console.error('Upload failed:', error);
} finally {
@ -309,31 +273,8 @@
}
}
function removeAttachment(index: number) {
selectedAttachments = selectedAttachments.filter((_, i) => i !== index);
}
function getRequiredCapabilities(attachments: ProcessedAttachment[]): Array<'vision' | 'audio' | 'video' | 'documents'> {
const capabilities: Array<'vision' | 'audio' | 'video' | 'documents'> = [];
attachments.forEach(attachment => {
switch (attachment.type) {
case 'image':
if (!capabilities.includes('vision')) capabilities.push('vision');
break;
case 'video':
if (!capabilities.includes('video')) capabilities.push('video');
break;
case 'audio':
if (!capabilities.includes('audio')) capabilities.push('audio');
break;
case 'document':
if (!capabilities.includes('documents')) capabilities.push('documents');
break;
}
});
return capabilities;
function removeImage(index: number) {
selectedImages = selectedImages.filter((_, i) => i !== index);
}
function openImageModal(imageUrl: string, fileName: string) {
@ -493,7 +434,7 @@
<Sidebar.Root
bind:open={sidebarOpen}
class="fill-device-height overflow-clip"
{...supportedAttachmentTypes.length > 0 ? omit(fileUpload.dropzone, ['onclick']) : {}}
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
>
<AppSidebar bind:searchModalOpen />
@ -655,45 +596,26 @@
</div>
{/if}
<div class="flex flex-grow flex-col">
{#if selectedAttachments.length > 0}
{#if selectedImages.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each selectedAttachments as attachment, index (attachment.storage_id)}
{#each selectedImages as image, index (image.storage_id)}
<div
class="group border-secondary-foreground/[0.08] bg-secondary-foreground/[0.02] hover:bg-secondary-foreground/10 relative flex h-12 max-w-full shrink-0 items-center justify-center gap-2 rounded-xl border border-solid p-2 transition-[width,height] duration-500"
class:w-12={attachment.type === 'image'}
class:w-auto={attachment.type !== 'image'}
class="group border-secondary-foreground/[0.08] bg-secondary-foreground/[0.02] hover:bg-secondary-foreground/10 relative flex h-12 w-12 max-w-full shrink-0 items-center justify-center gap-2 rounded-xl border border-solid p-0 transition-[width,height] duration-500"
>
{#if attachment.type === 'image'}
<button
type="button"
onclick={() => openImageModal(attachment.url, attachment.fileName)}
onclick={() => openImageModal(image.url, image.fileName || 'image')}
class="rounded-lg"
>
<img
src={attachment.url}
src={image.url}
alt="Uploaded"
class="size-8 rounded-lg object-cover opacity-100 transition-opacity"
class="size-10 rounded-lg object-cover opacity-100 transition-opacity"
/>
</button>
{:else if attachment.type === 'video'}
<div class="flex items-center gap-2">
<VideoIcon class="size-4 text-blue-500" />
<span class="text-xs text-muted-foreground truncate max-w-20">{attachment.fileName}</span>
</div>
{:else if attachment.type === 'audio'}
<div class="flex items-center gap-2">
<AudioIcon class="size-4 text-green-500" />
<span class="text-xs text-muted-foreground truncate max-w-20">{attachment.fileName}</span>
</div>
{:else if attachment.type === 'document'}
<div class="flex items-center gap-2">
<FileTextIcon class="size-4 text-orange-500" />
<span class="text-xs text-muted-foreground truncate max-w-20">{attachment.fileName}</span>
</div>
{/if}
<button
type="button"
onclick={() => removeAttachment(index)}
onclick={() => removeImage(index)}
class="bg-secondary hover:bg-muted absolute -top-1 -right-1 cursor-pointer rounded-full p-1 opacity-0 transition group-hover:opacity-100"
>
<XIcon class="h-3 w-3" />
@ -773,34 +695,8 @@
</Tooltip>
</div>
<div class="flex flex-wrap items-center gap-2 pr-2">
<ModelPicker requiredCapabilities={selectedAttachments.length > 0 ? getRequiredCapabilities(selectedAttachments) : []} />
<ModelPicker onlyImageModels={selectedImages.length > 0} />
<div class="flex items-center gap-2">
{#if currentModelSupportsReasoning}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
>
<BrainIcon class="!size-3" />
<span class="hidden whitespace-nowrap sm:inline">
{casing.camelToPascal(settings.reasoningEffort)}
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'high')}>
<BrainIcon class="size-4" />
High
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'medium')}>
<BrainIcon class="size-4" />
Medium
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'low')}>
<BrainIcon class="size-4" />
Low
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<button
type="button"
class={cn(
@ -812,7 +708,7 @@
<SearchIcon class="!size-3" />
<span class="hidden whitespace-nowrap sm:inline">Web search</span>
</button>
{#if supportedAttachmentTypes.length > 0}
{#if currentModelSupportsImages}
<button
type="button"
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
@ -824,15 +720,9 @@
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<PaperclipIcon class="!size-3" />
<ImageIcon class="!size-3" />
{/if}
<span class="hidden whitespace-nowrap sm:inline">
{#if supportedAttachmentTypes.length === 1 && supportedAttachmentTypes[0]}
Attach {supportedAttachmentTypes[0]}
{:else}
Attach files
{/if}
</span>
<span class="hidden whitespace-nowrap sm:inline">Attach image</span>
</button>
{/if}
{#if session.current !== null && message.current.trim() !== ''}
@ -883,20 +773,12 @@
</div>
</Sidebar.Inset>
{#if fileUpload.isDragging && supportedAttachmentTypes.length > 0}
{#if fileUpload.isDragging && currentModelSupportsImages}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-sm">
<div class="text-center">
<UploadIcon class="text-primary mx-auto mb-4 h-16 w-16" />
<p class="text-xl font-semibold">
{#if supportedAttachmentTypes.length === 1 && supportedAttachmentTypes[0]}
Add {supportedAttachmentTypes[0]}
{:else}
Add files
{/if}
</p>
<p class="mt-2 text-sm opacity-75">
Drop {supportedAttachmentTypes.length === 1 ? `${supportedAttachmentTypes[0]}s` : 'supported files'} here to attach to your message.
</p>
<p class="text-xl font-semibold">Add image</p>
<p class="mt-2 text-sm opacity-75">Drop an image here to attach it to your message.</p>
</div>
</div>
{/if}

View file

@ -63,7 +63,8 @@
let selectedCategory = $state<string | null>(null);
const userKeysQuery = useCachedQuery(api.user_keys.all, {
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '',
});
@ -75,7 +76,7 @@
</svelte:head>
<div class="flex h-svh flex-col items-center justify-center">
{#if prompt.current.length === 0 && userKeysQuery.data && Object.values(userKeysQuery.data).some(key => key !== null)}
{#if prompt.current.length === 0 && openRouterKeyQuery.data}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there <span class={{ 'blur-sm': settings.data?.privacy_mode }}
@ -130,7 +131,7 @@
{/if}
</div>
</div>
{:else if userKeysQuery.data && !Object.values(userKeysQuery.data).some(key => key !== null) && !userKeysQuery.isLoading}
{:else if !openRouterKeyQuery.data && !openRouterKeyQuery.isLoading}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there, <span class={{ 'blur-sm': settings.data?.privacy_mode }}

View file

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

View file

@ -5,7 +5,7 @@
import { CopyButton } from '$lib/components/ui/copy-button';
import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte';
import FilePreview from '$lib/components/ui/file-preview/file-preview.svelte';
import { ImageModal } from '$lib/components/ui/image-modal';
import { sanitizeHtml } from '$lib/utils/markdown-it';
import { on } from 'svelte/events';
import { isHtmlElement } from '$lib/utils/is';
@ -19,14 +19,6 @@
import { callGenerateMessage } from '../../api/generate-message/call';
import * as Icons from '$lib/components/icons';
import { settings } from '$lib/state/settings.svelte';
import ShinyText from '$lib/components/animations/shiny-text.svelte';
import ChevronRightIcon from '~icons/lucide/chevron-right';
import { AnnotationSchema, type Annotation } from '$lib/types';
import ExternalLinkIcon from '~icons/lucide/external-link';
import GlobeIcon from '~icons/lucide/globe';
import { Avatar } from 'melt/components';
import BrainIcon from '~icons/lucide/brain';
import * as casing from '$lib/utils/casing';
const style = tv({
base: 'prose rounded-xl p-2 max-w-full',
@ -46,6 +38,19 @@
let { message }: Props = $props();
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
open: false,
imageUrl: '',
fileName: '',
});
function openImageModal(imageUrl: string, fileName: string) {
imageModal = {
open: true,
imageUrl,
fileName,
};
}
async function createBranchedConversation() {
const res = await ResultAsync.fromPromise(
@ -81,27 +86,9 @@
await goto(`/chat/${cid}`);
}
const annotations = $derived.by(() => {
if (!message.annotations || message.annotations.length === 0) return null;
const annotations: Annotation[] = [];
for (const annotation of message.annotations) {
const parsed = AnnotationSchema.safeParse(annotation);
if (!parsed.success) continue;
annotations.push(parsed.data);
}
return annotations;
});
let showReasoning = $state(false);
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && message.reasoning?.length === 0 && !message.error)}
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
<div
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
{@attach (node) => {
@ -119,55 +106,21 @@
});
}}
>
{#if message.attachments && message.attachments.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each message.attachments as attachment (attachment.storage_id)}
<FilePreview
{attachment}
isUserMessage={message.role === 'user'}
compact={true}
/>
{/each}
</div>
{:else if message.images && message.images.length > 0}
<!-- Legacy image support -->
{#if message.images && message.images.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each message.images as image (image.storage_id)}
<FilePreview
attachment={{
type: 'image',
url: image.url,
fileName: image.fileName || 'image',
mimeType: image.mimeType || 'image/jpeg',
size: image.size || 0,
storage_id: image.storage_id
}}
isUserMessage={message.role === 'user'}
compact={true}
/>
{/each}
</div>
{/if}
{#if message.reasoning}
<div class="my-8">
<button
type="button"
class="text-muted-foreground flex items-center gap-1 pb-2 text-sm"
aria-label="Toggle reasoning"
onclick={() => (showReasoning = !showReasoning)}
onclick={() => openImageModal(image.url, image.fileName || 'image')}
class="rounded-lg"
>
<ChevronRightIcon class={cn('inline size-4', { 'rotate-90': showReasoning })} />
{#if message.content.length === 0}
<ShinyText>Reasoning...</ShinyText>
{:else}
<span>Reasoning</span>
{/if}
<img
src={image.url}
alt={image.fileName || 'Uploaded'}
class="max-w-xs rounded-lg transition-opacity hover:opacity-80"
/>
</button>
{#if showReasoning}
<div class="text-muted-foreground/50 bg-popover relative rounded-lg p-2 text-xs">
{message.reasoning}
</div>
{/if}
{/each}
</div>
{/if}
<div class={style({ role: message.role })}>
@ -193,58 +146,6 @@
</svelte:boundary>
{/if}
</div>
{#if annotations}
<div class="flex items-center gap-2">
<span class="text-muted-foreground pl-2 text-xs">
{annotations.length}
{annotations.length === 1 ? 'Citation' : 'Citations'}
</span>
<div class="flex items-center">
{#each annotations as annotation}
{#if annotation.type === 'url_citation'}
{@const url = new URL(annotation.url_citation.url)}
<a
href={annotation.url_citation.url}
target="_blank"
class="border-border bg-background bg-noise -m-1 flex place-items-center justify-center rounded-full border p-0.5 transition-transform hover:scale-110"
>
{@render siteIcon({ url })}
</a>
{/if}
{/each}
</div>
</div>
<div class="scrollbar-hide flex place-items-center gap-2 overflow-x-auto p-2">
{#each annotations as annotation}
{#if annotation.type === 'url_citation'}
{@const url = new URL(annotation.url_citation.url)}
<div
class="border-border hover:border-primary/50 text-muted-foreground group relative flex h-32 min-w-60 flex-col justify-between rounded-lg border p-4 transition-colors"
>
<div>
<a
href={annotation.url_citation.url}
target="_blank"
class="group-hover:text-foreground block max-w-full truncate font-medium transition-colors"
>
<span class="absolute inset-0"></span>
{annotation.url_citation.title}
</a>
<p class="truncate text-sm">
{annotation.url_citation.content}
</p>
</div>
<span class="flex items-center gap-2 text-xs">
{@render siteIcon({ url })}
{url.hostname}
</span>
<ExternalLinkIcon class="text-primary absolute top-2 right-2 size-3" />
</div>
{/if}
{/each}
</div>
{/if}
<div
class={cn(
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
@ -287,16 +188,8 @@
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}
{#if message.reasoning_effort}
<span class="text-muted-foreground text-xs">
<BrainIcon class="inline-block size-4 shrink-0 text-green-500" />
{casing.camelToPascal(message.reasoning_effort)}
</span>
{/if}
{#if message.web_search_enabled}
<span class="text-muted-foreground text-xs">
<GlobeIcon class="text-primary inline-block size-4 shrink-0" />
</span>
<span class="text-muted-foreground text-xs"> Web search enabled </span>
{/if}
{#if message.cost_usd !== undefined}
@ -308,15 +201,11 @@
</div>
</div>
{#if message.images && message.images.length > 0}
<ImageModal
bind:open={imageModal.open}
imageUrl={imageModal.imageUrl}
fileName={imageModal.fileName}
/>
{/if}
{#snippet siteIcon({ url }: { url: URL })}
<Avatar src={`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`}>
{#snippet children(avatar)}
<img {...avatar.image} alt={`${url.hostname} site icon`} />
<span {...avatar.fallback}>
<GlobeIcon class="inline-block size-4 shrink-0" />
</span>
{/snippet}
</Avatar>
{/snippet}
{/if}

View file

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

@ -1 +0,0 @@
Subproject commit 73461f942496d91e098d2d3d61c769571a13cb11