Compare commits

..

10 commits

Author SHA1 Message Date
c77f5fb877 feat: Add previews for all supported file types 2025-09-02 02:46:38 +05:30
7434d98fc1 refactor: Clean up more unused code 2025-09-01 00:28:47 +05:30
bca38fa221 refactor: Clean up unused code 2025-09-01 00:24:51 +05:30
12b4fef96d feat: Add support for audio, video and document file types 2025-08-31 23:59:15 +05:30
31d72543b3 feat: Complete migration to kepler-ai-sdk 2025-08-31 17:09:09 +05:30
071e1016b1 feat: Preliminary support for kepler-ai-sdk 2025-08-30 20:52:03 +05:30
Aidan Bleser
f8f6748bec
feat: go back to last chat on back to chat button (#47) 2025-08-11 03:51:27 -07:00
Aidan Bleser
a77493c9ef fix ugly FF bug 2025-07-10 07:08:24 -05:00
Aidan Bleser
7b9595e571
Post Hackathon Stuff (#40)
Co-authored-by: Thomas G. Lopes <26071571+TGlide@users.noreply.github.com>
2025-07-10 04:45:02 -07:00
Thomas G. Lopes
a96ba2152b issue templates, no code changes 2025-06-20 17:16:12 +01:00
72 changed files with 6581 additions and 1176 deletions

View file

@ -13,4 +13,11 @@ GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
OPENROUTER_FREE_KEY= # 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=

42
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,42 @@
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

@ -0,0 +1,35 @@
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

3
.gitignore vendored
View file

@ -24,4 +24,5 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.aider* .aider*
src/lib/backend/convex/_generated src/lib/backend/convex/_generated
tmp/

8
.mcp.json Normal file
View file

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

1580
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4", "@testing-library/svelte": "^5.2.4",
"@vercel/functions": "^2.2.0", "@vercel/functions": "^2.2.0",
"bits-ui": "^2.8.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"convex": "^1.24.8", "convex": "^1.24.8",
@ -44,9 +45,14 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"isomorphic-dompurify": "^2.25.0", "isomorphic-dompurify": "^2.25.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f", "melt": "^0.38.0",
"mode-watcher": "^1.0.8", "mode-watcher": "^1.0.8",
"neverthrow": "^8.2.0", "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": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@ -58,6 +64,7 @@
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0", "tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.20.0",
"unplugin-icons": "^22.1.0", "unplugin-icons": "^22.1.0",
@ -80,11 +87,11 @@
"@fontsource-variable/nunito-sans": "^5.2.6", "@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6", "@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6", "@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "file:./tmp/kepler-ai-sdk",
"better-auth": "^1.2.9", "better-auth": "^1.2.9",
"convex-helpers": "^0.1.94", "convex-helpers": "^0.1.94",
"hastscript": "^9.0.1", "hastscript": "^9.0.1",
"markdown-it-async": "^2.2.0", "markdown-it-async": "^2.2.0",
"openai": "^5.3.0",
"zod": "^3.25.64" "zod": "^3.25.64"
} }
} }

100
pnpm-lock.yaml generated
View file

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

View file

@ -4,6 +4,8 @@
@import '@fontsource-variable/nunito-sans'; @import '@fontsource-variable/nunito-sans';
@import '@fontsource/instrument-serif'; @import '@fontsource/instrument-serif';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
@ -341,6 +343,16 @@
overscroll-behavior: contain; 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 { .modal-top {
@apply place-items-start; @apply place-items-start;

View file

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

View file

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

View file

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

View file

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

View file

@ -89,26 +89,6 @@ export const set = mutation({
await ctx.db.replace(existing._id, userKey); await ctx.db.replace(existing._id, userKey);
} else { } else {
await ctx.db.insert('user_keys', userKey); 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,41 +26,6 @@ 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({ export const set = mutation({
args: { args: {
privacy_mode: v.boolean(), privacy_mode: v.boolean(),
@ -86,7 +51,6 @@ export const set = mutation({
await ctx.db.insert('user_settings', { await ctx.db.insert('user_settings', {
user_id: s.userId, user_id: s.userId,
privacy_mode: args.privacy_mode, privacy_mode: args.privacy_mode,
free_messages_used: 0,
}); });
} else { } else {
await ctx.db.patch(existing._id, { await ctx.db.patch(existing._id, {
@ -105,7 +69,6 @@ export const create = mutation({
await ctx.db.insert('user_settings', { await ctx.db.insert('user_settings', {
user_id: args.user_id, user_id: args.user_id,
privacy_mode: false, privacy_mode: false,
free_messages_used: 0,
}); });
}, },
}); });

View file

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

View file

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

View file

@ -0,0 +1,640 @@
<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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,512 @@
<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

@ -0,0 +1,379 @@
<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

@ -0,0 +1,97 @@
<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

@ -0,0 +1,149 @@
<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

@ -0,0 +1,321 @@
<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,6 +14,7 @@
primary: 'bg-primary text-primary-foreground', primary: 'bg-primary text-primary-foreground',
}, },
size: { 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', 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', default: 'min-w-8 gap-1.5 p-1 px-2',
lg: 'min-w-9 gap-2 p-1 px-3 text-lg', lg: 'min-w-9 gap-2 p-1 px-3 text-lg',

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
// 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

@ -0,0 +1,160 @@
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

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

View file

@ -0,0 +1,23 @@
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,13 +1,17 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import { getModelKey } from '$lib/backend/convex/user_enabled_models'; 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 { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { createInit } from '$lib/spells/create-init.svelte'; import { createInit } from '$lib/spells/create-init.svelte';
import { Provider } from '$lib/types'; import { Provider } from '$lib/types';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
import { watch } from 'runed'; import { watch } from 'runed';
import { session } from './session.svelte'; import { session } from './session.svelte';
export interface ModelWithEnabledStatus extends ModelInfo {
enabled: boolean;
}
export class Models { export class Models {
enabled = $state({} as Record<string, unknown>); enabled = $state({} as Record<string, unknown>);
@ -23,14 +27,104 @@ export class Models {
); );
}); });
from<P extends Provider>(provider: Provider) { /**
return page.data.models[provider].map((m: { id: string }) => { * Get models from a specific provider with enabled status
return { */
...m, from(provider: Provider): ModelWithEnabledStatus[] {
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined, const providerModels = page.data.models[provider] || [];
}; return providerModels.map((model: ModelInfo) => ({
}) as Array<ProviderModelMap[P] & { enabled: boolean }>; ...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);
});
} }
} }
export const models = new Models(); export const models = new Models();

View file

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

View file

@ -1,8 +1,12 @@
import { z } from 'zod';
export const Provider = { export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
OpenAI: 'openai', OpenAI: 'openai',
Anthropic: 'anthropic', Anthropic: 'anthropic',
Gemini: 'gemini',
Mistral: 'mistral',
Cohere: 'cohere',
OpenRouter: 'openrouter',
} as const; } as const;
export type Provider = (typeof Provider)[keyof typeof Provider]; export type Provider = (typeof Provider)[keyof typeof Provider];
@ -11,6 +15,62 @@ export type ProviderMeta = {
title: string; title: string;
link: string; link: string;
description: string; description: string;
models?: string[]; placeholder: 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-...',
},
}; };

View file

@ -0,0 +1,40 @@
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(',');
}

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

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

36
src/lib/utils/file.ts Normal file
View file

@ -0,0 +1,36 @@
/**
* 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

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

View file

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

View file

@ -1,34 +1,61 @@
import { Result, ResultAsync } from 'neverthrow'; import { Result, ResultAsync } from 'neverthrow';
import { Provider, PROVIDER_META } from '$lib/types';
export type OpenRouterApiKeyData = { export type ProviderApiKeyData = {
label: string; label: string;
usage: number; usage?: number;
is_free_tier: boolean; is_free_tier?: boolean;
is_provisioning_key: boolean; is_provisioning_key?: boolean;
limit: number; limit?: number;
limit_remaining: number; limit_remaining?: number;
valid: boolean;
}; };
export const OpenRouter = { export const ProviderUtils = {
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => { /**
* Validate an API key for a specific provider via server endpoint
*/
validateApiKey: async (provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> => {
return await ResultAsync.fromPromise( return await ResultAsync.fromPromise(
(async () => { (async () => {
const res = await fetch('https://openrouter.ai/api/v1/key', { const response = await fetch('/api/validate-key', {
method: 'POST',
headers: { headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ provider, key }),
}); });
if (!res.ok) throw new Error('Failed to get API key'); if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const { data } = await res.json(); const result = await response.json();
return result.data;
if (!data) throw new Error('No info returned for api key');
return data as OpenRouterApiKeyData;
})(), })(),
(e) => `Failed to get API key ${e}` (e) => `Failed to validate 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,17 +1,19 @@
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router'; import { loadUserModels, loadGuestModels } from '$lib/services/model-loader.server';
import { Provider } from '$lib/types';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals }) => {
const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]); const session = await locals.auth();
// Load models based on user's API keys
const models = session?.session?.token
? await loadUserModels(session.session.token)
: loadGuestModels();
return { return {
session, session,
models: { models,
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
},
}; };
}; };
// Makes caching easier, and tbf, we don't need SSR anyways here // Enable SSR for better performance
export const ssr = true; export const ssr = true;

View file

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

View file

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

View file

@ -1,45 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Provider, type ProviderMeta } from '$lib/types'; import { Provider, PROVIDER_META } from '$lib/types';
import ProviderCard from './provider-card.svelte'; import ProviderCard from './provider-card.svelte';
const allProviders = Object.values(Provider); 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> </script>
<svelte:head> <svelte:head>
@ -49,17 +12,13 @@
<div> <div>
<h1 class="text-2xl font-bold">API Keys</h1> <h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm"> <h2 class="text-muted-foreground mt-2 text-sm">
Bring your own API keys for select models. Messages sent using your API keys will not count Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
towards your monthly limits.
</h2> </h2>
</div> </div>
<div class="mt-8 flex flex-col gap-4"> <div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)} {#each allProviders as provider (provider)}
<!-- only do OpenRouter for now --> {@const meta = PROVIDER_META[provider]}
{#if provider === Provider.OpenRouter} <ProviderCard {provider} {meta} />
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
{/if}
{/each} {/each}
</div> </div>

View file

@ -12,7 +12,7 @@
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { ResultAsync } from 'neverthrow'; import { ResultAsync } from 'neverthrow';
import { resource } from 'runed'; import { resource } from 'runed';
import * as providers from '$lib/utils/providers'; import { ProviderUtils } from '$lib/utils/providers';
type Props = { type Props = {
provider: Provider; provider: Provider;
@ -65,11 +65,8 @@
async (key) => { async (key) => {
if (!key) return null; if (!key) return null;
if (provider === Provider.OpenRouter) { const result = await ProviderUtils.validateApiKey(provider, key);
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null); return result.unwrapOr(null);
}
return null;
} }
); );
</script> </script>
@ -99,11 +96,17 @@
{#if apiKeyInfoResource.loading} {#if apiKeyInfoResource.loading}
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div> <div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
{:else if apiKeyInfoResource.current} {:else if apiKeyInfoResource.current}
<span class="text-muted-foreground flex h-6 place-items-center text-xs"> {#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined && apiKeyInfoResource.current.usage !== null && apiKeyInfoResource.current.limit_remaining !== null}
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed( <span class="text-muted-foreground flex h-6 place-items-center text-xs">
3 ${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
)} remaining 3
</span> )} remaining
</span>
{:else}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
✅ API key is valid
</span>
{/if}
{:else} {:else}
<span <span
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500" 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,65 +5,86 @@
import { Search } from '$lib/components/ui/search'; import { Search } from '$lib/components/ui/search';
import { models } from '$lib/state/models.svelte'; import { models } from '$lib/state/models.svelte';
import { session } from '$lib/state/session.svelte'; import { session } from '$lib/state/session.svelte';
import { Provider } from '$lib/types.js'; import { page } from '$app/state';
import { Provider, PROVIDER_META } from '$lib/types.js';
import { fuzzysearch } from '$lib/utils/fuzzy-search'; import { fuzzysearch } from '$lib/utils/fuzzy-search';
import { cn } from '$lib/utils/utils'; import { cn } from '$lib/utils/utils';
import { Toggle } from 'melt/builders'; import { Toggle } from 'melt/builders';
import PlusIcon from '~icons/lucide/plus'; import PlusIcon from '~icons/lucide/plus';
import XIcon from '~icons/lucide/x'; import XIcon from '~icons/lucide/x';
import ModelCard from './model-card.svelte'; import ModelCard from './model-card.svelte';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { // Get all user's API keys to determine which providers are available
provider: Provider.OpenRouter, const userKeysQuery = useCachedQuery(api.user_keys.all, {
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
}); });
const hasOpenRouterKey = $derived(
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== '' // 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);
});
let search = $state(''); let search = $state('');
let selectedProvider = $state<Provider | 'all'>('all');
const openRouterToggle = new Toggle({ // Filter toggles
value: true, const reasoningModelsToggle = new Toggle({
// TODO: enable this if and when when we use multiple providers
disabled: true,
});
const freeModelsToggle = new Toggle({
value: false, value: false,
disabled: false,
}); });
let initiallyEnabled = $state<string[]>([]); const imageModelsToggle = new Toggle({
$effect(() => { value: false,
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) { });
initiallyEnabled = models
.from(Provider.OpenRouter) const streamingModelsToggle = new Toggle({
.filter((m) => m.enabled) value: false,
.map((m) => m.id); });
// Get models based on current filters
const filteredModels = $derived.by(() => {
let modelList = selectedProvider === 'all'
? models.all()
: models.from(selectedProvider);
// Apply capability filters
if (reasoningModelsToggle.value) {
modelList = modelList.filter(m => m.capabilities.reasoning);
} }
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,
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 openRouterModels = $derived( const hasAnyApiKeys = $derived(availableProviders.length > 0);
fuzzysearch({
haystack: models.from(Provider.OpenRouter).filter((m) => {
if (!freeModelsToggle.value) return true;
if (m.pricing.prompt === '0') return true;
return false;
}),
needle: search,
property: 'name',
}).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> </script>
<svelte:head> <svelte:head>
@ -75,58 +96,125 @@
Choose which models appear in your model selector. This won't affect existing conversations. Choose which models appear in your model selector. This won't affect existing conversations.
</h2> </h2>
<div class="mt-4 flex flex-col gap-2"> {#if !hasAnyApiKeys}
<Search bind:value={search} placeholder="Search models" /> <div class="mt-8 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div class="flex place-items-center gap-2"> <h3 class="font-semibold text-yellow-800">No API Keys Configured</h3>
<button <p class="text-sm text-yellow-700 mt-1">
{...openRouterToggle.trigger} You need to add API keys for at least one provider to see and manage models.
aria-label="OpenRouter" <a href="/account/api-keys" class="underline hover:text-yellow-900">Go to API Keys Settings</a>
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" </p>
>
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
<button
{...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"
>
Free
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
</div> </div>
</div> {:else}
<div class="mt-6 space-y-4">
<!-- Search -->
<Search bind:value={search} placeholder="Search models" />
{#if openRouterModels.length > 0} <!-- Provider and filter tabs -->
<div class="mt-4 flex flex-col gap-4"> <div class="flex flex-wrap items-center gap-2">
<div> <!-- Provider selector -->
<h3 class="text-lg font-bold">OpenRouter</h3> <div class="flex items-center gap-1">
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p> <button
</div> onclick={() => selectedProvider = 'all'}
<div class="relative"> class={cn(
<div "px-3 py-1 rounded-full text-sm transition-all",
class={cn('flex flex-col gap-4 overflow-hidden', { selectedProvider === 'all'
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey, ? "bg-primary text-primary-foreground"
})} : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
)}
>
All Providers ({models.all().length})
</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"
)}
>
{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"
)}
> >
{#each openRouterModels as model (model.id)} 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
</button>
</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}
</div>
{:else}
<div class="grid gap-4">
{#each filteredModels as model (model.id)}
<ModelCard <ModelCard
provider={Provider.OpenRouter} provider={model.provider as Provider}
{model} {model}
enabled={model.enabled} enabled={model.enabled}
disabled={!hasOpenRouterKey} disabled={false}
/> />
{/each} {/each}
</div> </div>
{#if !hasOpenRouterKey} {/if}
<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> </div>
{/if} {/if}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Provider } from '$lib/types'; import type { Provider } from '$lib/types';
import { PROVIDER_META } from '$lib/types';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
@ -7,16 +8,16 @@
import { session } from '$lib/state/session.svelte.js'; import { session } from '$lib/state/session.svelte.js';
import { ResultAsync } from 'neverthrow'; import { ResultAsync } from 'neverthrow';
import { getFirstSentence } from '$lib/utils/strings'; import { getFirstSentence } from '$lib/utils/strings';
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
type Model = { import Tooltip from '$lib/components/ui/tooltip.svelte';
id: string; import EyeIcon from '~icons/lucide/eye';
name: string; import BrainIcon from '~icons/lucide/brain';
description: string; import ZapIcon from '~icons/lucide/zap';
}; import CpuIcon from '~icons/lucide/cpu';
type Props = { type Props = {
provider: Provider; provider: Provider;
model: Model; model: ModelInfo;
enabled?: boolean; enabled?: boolean;
disabled?: boolean; disabled?: boolean;
}; };
@ -24,15 +25,23 @@
let { provider, model, enabled = false, disabled = false }: Props = $props(); let { provider, model, enabled = false, disabled = false }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const providerMeta = $derived(PROVIDER_META[provider]);
const [shortDescription, fullDescription] = $derived(getFirstSentence(model.description)); const [shortDescription, fullDescription] = $derived(
model.description ? getFirstSentence(model.description) : [null, model.name]
);
let showMore = $state(false); let showMore = $state(false);
async function toggleEnabled(v: boolean) { async function toggleEnabled(v: boolean) {
console.log('toggleEnabled called:', { provider, model_id: model.id, enabled: v });
enabled = v; // Optimistic! enabled = v; // Optimistic!
if (!session.current?.user.id) return; if (!session.current?.user.id) {
console.log('No user session, returning early');
return;
}
console.log('Calling Convex mutation...');
const res = await ResultAsync.fromPromise( const res = await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.set, { client.mutation(api.user_enabled_models.set, {
provider, provider,
@ -43,31 +52,138 @@
(e) => e (e) => e
); );
if (res.isErr()) enabled = !v; // Should have been a realist :( if (res.isErr()) {
console.error('Mutation failed:', res.error);
enabled = !v; // Revert on error
} else {
console.log('Mutation succeeded');
}
} }
// 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> </script>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex place-items-center gap-2"> <div class="flex flex-col gap-1">
<Card.Title>{model.name}</Card.Title> <div class="flex place-items-center gap-2">
<span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span> <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>
</div>
<span class="text-muted-foreground text-xs">{model.id}</span>
</div> </div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} /> <Switch bind:value={enabled} onValueChange={toggleEnabled} {disabled} />
</div> </div>
<Card.Description
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description {#if model.description}
> <Card.Description>
{#if shortDescription !== null} {showMore ? fullDescription : (shortDescription ?? fullDescription)}
<button </Card.Description>
type="button" {#if shortDescription !== null}
class="text-muted-foreground w-fit text-start text-xs" <button
onclick={() => (showMore = !showMore)} type="button"
{disabled} class="text-muted-foreground w-fit text-start text-xs hover:text-foreground transition-colors"
> onclick={() => (showMore = !showMore)}
{showMore ? 'Show less' : 'Show more'} {disabled}
</button> >
{showMore ? 'Show less' : 'Show more'}
</button>
{/if}
{/if} {/if}
</Card.Header> </Card.Header>
</Card.Root>
<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,15 +1,12 @@
import { error, json, type RequestHandler } from '@sveltejs/kit'; import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ResultAsync } from 'neverthrow'; import { ResultAsync } from 'neverthrow';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { OpenAI } from 'openai';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import { parseMessageForRules } from '$lib/utils/rules'; import { parseMessageForRules } from '$lib/utils/rules';
import { Provider } from '$lib/types'; import { createModelManager } from '$lib/services/model-manager';
import type { UserApiKeys } from '$lib/services/model-manager';
const FREE_MODEL = 'google/gemma-3-27b-it';
const reqBodySchema = z.object({ const reqBodySchema = z.object({
prompt: z.string(), prompt: z.string(),
@ -31,6 +28,29 @@ 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 }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const bodyResult = await ResultAsync.fromPromise( const bodyResult = await ResultAsync.fromPromise(
request.json(), request.json(),
@ -53,42 +73,63 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return error(401, 'You must be logged in to enhance a prompt'); return error(401, 'You must be logged in to enhance a prompt');
} }
const [rulesResult, keyResult] = await Promise.all([ // Get user API keys
ResultAsync.fromPromise( const userApiKeys = await getUserApiKeys(session.session.token);
client.query(api.user_rules.all, { if (!userApiKeys) {
session_token: session.session.token, return error(500, 'Failed to get user API keys');
}), }
(e) => `Failed to get rules: ${e}`
), const hasAnyKey = Object.values(userApiKeys).some((key) => key);
ResultAsync.fromPromise( if (!hasAnyKey) {
client.query(api.user_keys.get, { return error(
provider: Provider.OpenRouter, 400,
session_token: session.session.token, 'No API keys configured. Please add at least one provider API key in settings to enhance prompts.'
}), );
(e) => `Failed to get API key: ${e}` }
),
]); // Get user rules for context
const rulesResult = await ResultAsync.fromPromise(
client.query(api.user_rules.all, {
session_token: session.session.token,
}),
(e) => `Failed to get rules: ${e}`
);
if (rulesResult.isErr()) { if (rulesResult.isErr()) {
return error(500, 'Failed to get rules'); return error(500, 'Failed to get rules');
} }
if (keyResult.isErr()) {
return error(500, 'Failed to get key');
}
const mentionedRules = parseMessageForRules( const mentionedRules = parseMessageForRules(
args.prompt, args.prompt,
rulesResult.value.filter((r) => r.attach === 'manual') rulesResult.value.filter((r) => r.attach === 'manual')
); );
const openai = new OpenAI({ // Initialize model manager with user's API keys
baseURL: 'https://openrouter.ai/api/v1', const modelManager = createModelManager();
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY, 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 enhancePrompt = ` 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. 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.
You can do this by fixing spelling/grammatical errors, clarifying details, and removing unnecessary wording where possible. You can do this by fixing spelling/grammatical errors, clarifying details, and removing unnecessary wording where possible.
Only return the enhanced prompt, nothing else. Do NOT wrap it in quotes, do NOT use markdown. Only return the enhanced prompt, nothing else. Do NOT wrap it in quotes, do NOT use markdown.
Do NOT respond to the prompt only optimize it so that another LLM can understand it better. Do NOT respond to the prompt only optimize it so that another LLM can understand it better.
@ -107,23 +148,24 @@ ${args.prompt}
`; `;
const enhancedResult = await ResultAsync.fromPromise( const enhancedResult = await ResultAsync.fromPromise(
openai.chat.completions.create({ provider.generateCompletion({
model: FREE_MODEL, model: enhanceModel.id,
messages: [{ role: 'user', content: enhancePrompt }], messages: [{ role: 'user', content: enhancePrompt }],
temperature: 0.5, temperature: 0.5,
maxTokens: 1000,
}), }),
(e) => `Enhance prompt API call failed: ${e}` (e) => `Enhance prompt API call failed: ${e}`
); );
if (enhancedResult.isErr()) { if (enhancedResult.isErr()) {
return error(500, 'error enhancing the prompt'); return error(500, 'Error enhancing the prompt');
} }
const enhancedResponse = enhancedResult.value; const enhancedResponse = enhancedResult.value;
const enhanced = enhancedResponse.choices[0]?.message?.content; const enhanced = enhancedResponse.content?.trim();
if (!enhanced) { if (!enhanced) {
return error(500, 'error enhancing the prompt'); return error(500, 'Error enhancing the prompt');
} }
return response({ return response({

View file

@ -1,19 +1,19 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public'; 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 { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel'; import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { Provider } from '$lib/types'; import { Provider, type Annotation } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit'; import { error, json, type RequestHandler } from '@sveltejs/kit';
import { waitUntil } from '@vercel/functions'; import { waitUntil } from '@vercel/functions';
import { getSessionCookie } from 'better-auth/cookies'; import { getSessionCookie } from 'better-auth/cookies';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import { err, ok, Result, ResultAsync } from 'neverthrow'; import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js'; import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js'; import { md } from '$lib/utils/markdown-it.js';
import * as array from '$lib/utils/array'; import * as array from '$lib/utils/array';
import { parseMessageForRules } from '$lib/utils/rules.js'; 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 // Set to true to enable debug logging
const ENABLE_LOGGING = true; const ENABLE_LOGGING = true;
@ -22,24 +22,26 @@ const reqBodySchema = z
.object({ .object({
message: z.string().optional(), message: z.string().optional(),
model_id: z.string(), model_id: z.string(),
session_token: z.string(), session_token: z.string(),
conversation_id: z.string().optional(), conversation_id: z.string().optional(),
web_search_enabled: z.boolean().optional(), web_search_enabled: z.boolean().optional(),
images: z attachments: z
.array( .array(
z.object({ z.object({
type: z.enum(['image', 'video', 'audio', 'document']),
url: z.string(), url: z.string(),
storage_id: z.string(), storage_id: z.string(),
fileName: z.string().optional(), fileName: z.string(),
mimeType: z.string(),
size: z.number(),
}) })
) )
.optional(), .optional(),
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
}) })
.refine( .refine(
(data) => { (data) => {
if (data.conversation_id === undefined && data.message === undefined) return false; if (data.conversation_id === undefined && data.message === undefined) return false;
return true; return true;
}, },
{ {
@ -66,34 +68,45 @@ function log(message: string, startTime: number): void {
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL); 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({ async function generateConversationTitle({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
keyResultPromise,
userMessage, userMessage,
modelManager,
}: { }: {
conversationId: string; conversationId: string;
sessionToken: string; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>;
userMessage: string; userMessage: string;
modelManager: ChatModelManager;
}) { }) {
log('Starting conversation title generation', startTime); log('Starting conversation title generation', startTime);
const keyResult = await keyResultPromise; // Check if conversation currently has default title
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( const conversationResult = await ResultAsync.fromPromise(
client.query(api.conversations.get, { client.query(api.conversations.get, {
session_token: sessionToken, session_token: sessionToken,
@ -114,12 +127,25 @@ async function generateConversationTitle({
return; return;
} }
const openai = new OpenAI({ // Try to find a fast, cheap model for title generation
baseURL: 'https://openrouter.ai/api/v1', const availableModels = await modelManager.listAvailableModels();
apiKey: actualKey, 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;
}
// Create a prompt for title generation using only the first user message
const titlePrompt = `Based on this message: const titlePrompt = `Based on this message:
"""${userMessage}""" """${userMessage}"""
@ -128,26 +154,25 @@ 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. 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( const titleResult = await ResultAsync.fromPromise(
openai.chat.completions.create({ provider.generateCompletion({
model: 'mistralai/ministral-8b', model: titleModel.id,
messages: [{ role: 'user', content: titlePrompt }], messages: [{ role: 'user', content: titlePrompt }],
max_tokens: 20, maxTokens: 1024,
temperature: 0.5, temperature: 0.5,
}), }),
(e) => `Title generation API call failed: ${e}` (e) => `Title generation API call failed: ${e}`
); );
if (titleResult.isErr()) { if (titleResult.isErr()) {
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime); log(`Title generation: API call failed: ${titleResult.error}`, startTime);
return; return;
} }
const titleResponse = titleResult.value; const titleResponse = titleResult.value;
const rawTitle = titleResponse.choices[0]?.message?.content?.trim(); const rawTitle = titleResponse.content?.trim();
if (!rawTitle) { if (!rawTitle) {
log('Title generation: No title generated', startTime); log('Title generation: No title generated', startTime);
@ -179,20 +204,20 @@ async function generateAIResponse({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
modelResultPromise, modelId,
keyResultPromise, modelManager,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal, abortSignal,
reasoningEffort,
}: { }: {
conversationId: string; conversationId: string;
sessionToken: string; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>; modelId: string;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>; modelManager: ChatModelManager;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>; rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
reasoningEffort?: 'low' | 'medium' | 'high';
}) { }) {
log('Starting AI response generation in background', startTime); log('Starting AI response generation in background', startTime);
@ -201,36 +226,11 @@ async function generateAIResponse({
return; return;
} }
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] = // Get model and provider
await Promise.all([ const model = await modelManager.getModel(modelId);
modelResultPromise,
keyResultPromise,
ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: sessionToken,
}),
(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) { if (!model) {
handleGenerationError({ handleGenerationError({
error: 'Model not found or not enabled', error: `Model ${modelId} not found or not available`,
conversationId, conversationId,
messageId: undefined, messageId: undefined,
sessionToken, sessionToken,
@ -239,7 +239,30 @@ async function generateAIResponse({
return; return;
} }
log('Background: Model found and enabled', startTime); 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([
ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: sessionToken,
}),
(e) => `Failed to get messages: ${e}`
),
rulesResultPromise,
]);
if (messagesQueryResult.isErr()) { if (messagesQueryResult.isErr()) {
handleGenerationError({ handleGenerationError({
@ -259,14 +282,14 @@ async function generateAIResponse({
const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false; const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id; const finalModelId = webSearchEnabled ? `${modelId}:online` : modelId;
// Create assistant message // Create assistant message
const messageCreationResult = await ResultAsync.fromPromise( const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, { client.mutation(api.messages.create, {
conversation_id: conversationId, conversation_id: conversationId,
model_id: model.model_id, model_id: modelId,
provider: Provider.OpenRouter, provider: model.provider as Provider,
content: '', content: '',
role: 'assistant', role: 'assistant',
session_token: sessionToken, session_token: sessionToken,
@ -289,84 +312,6 @@ async function generateAIResponse({
const mid = messageCreationResult.value; const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime); 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()) { if (rulesResult.isErr()) {
handleGenerationError({ handleGenerationError({
error: `rules query failed: ${rulesResult.error}`, error: `rules query failed: ${rulesResult.error}`,
@ -402,7 +347,7 @@ async function generateAIResponse({
attachedRules.push(...parsedRules); attachedRules.push(...parsedRules);
} }
// remove duplicates // Remove duplicates
attachedRules = array.fromMap( attachedRules = array.fromMap(
array.toMap(attachedRules, (r) => [r._id, r]), array.toMap(attachedRules, (r) => [r._id, r]),
(_k, v) => v (_k, v) => v
@ -410,24 +355,20 @@ async function generateAIResponse({
log(`Background: ${attachedRules.length} rules attached`, startTime); log(`Background: ${attachedRules.length} rules attached`, startTime);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
const formattedMessages = messages.map((m) => { const formattedMessages = messages.map((m) => {
if (m.images && m.images.length > 0 && m.role === 'user') { if (m.attachments && m.attachments.length > 0 && m.role === 'user') {
return { const contentParts = [
role: 'user' as const, { type: 'text', text: m.content },
content: [ ...m.attachments.map(attachment => ({
{ type: 'text' as const, text: m.content }, type: attachment.type,
...m.images.map((img) => ({ [`${attachment.type}Url`]: attachment.url,
type: 'image_url' as const, mimeType: attachment.mimeType,
image_url: { url: img.url }, }))
})), ];
],
}; return { role: 'user' as const, content: contentParts };
} }
return { return {
role: m.role as 'user' | 'assistant' | 'system', role: m.role as 'user' | 'assistant' | 'system',
content: m.content, content: m.content,
@ -459,24 +400,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return; return;
} }
const streamResult = await ResultAsync.fromPromise( // Generate streaming completion
openai.chat.completions.create( let stream: AsyncIterable<any>;
{ try {
model: modelId, stream = provider.streamCompletion({
messages: messagesToSend, model: finalModelId,
temperature: 0.7, messages: messagesToSend,
stream: true, temperature: 0.7,
}, ...(reasoningEffort && { reasoning_effort: reasoningEffort }),
{ });
signal: abortSignal, log('Background: Stream created successfully', startTime);
} } catch (error) {
),
(e) => `OpenAI API call failed: ${e}`
);
if (streamResult.isErr()) {
handleGenerationError({ handleGenerationError({
error: `Failed to create stream: ${streamResult.error}`, error: `Failed to create stream: API call failed: ${error}`,
conversationId, conversationId,
messageId: mid, messageId: mid,
sessionToken, sessionToken,
@ -485,14 +421,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return; return;
} }
const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime);
let content = ''; let content = '';
let reasoning = '';
let chunkCount = 0; let chunkCount = 0;
let generationId: string | null = null; let generationId: string | null = null;
const annotations: Annotation[] = [];
try { try {
// Handle streaming response
for await (const chunk of stream) { for await (const chunk of stream) {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime); log('AI response generation aborted during streaming', startTime);
@ -500,25 +436,40 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
} }
chunkCount++; chunkCount++;
content += chunk.choices[0]?.delta?.content || '';
if (!content) continue;
generationId = chunk.id; // 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 || [];
const updateResult = await ResultAsync.fromPromise( reasoning += chunkReasoning;
client.mutation(api.messages.updateContent, { content += chunkContent;
message_id: mid, annotations.push(...chunkAnnotations);
content,
session_token: sessionToken,
}),
(e) => `Failed to update message content: ${e}`
);
if (updateResult.isErr()) { if (!chunkContent && !chunkReasoning) continue;
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`, generationId = chunk.id || generationId;
startTime
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}`
); );
if (updateResult.isErr()) {
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
}
} }
} }
@ -527,50 +478,20 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
startTime startTime
); );
if (!generationId) { // Final message update with completion stats
log('Background: No generation id found', startTime);
return;
}
const contentHtmlResultPromise = ResultAsync.fromPromise( const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content), md.renderAsync(content),
(e) => `Failed to render HTML: ${e}` (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 contentHtmlResult = await contentHtmlResultPromise;
if (contentHtmlResult.isErr()) { const [updateMessageResult, updateGeneratingResult] = await Promise.all([
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
ResultAsync.fromPromise( ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, { client.mutation(api.messages.updateMessage, {
message_id: mid, message_id: mid,
token_count: generationStats.tokens_completion, token_count: undefined, // Will be calculated by provider if available
cost_usd: generationStats.total_cost, cost_usd: undefined, // Will be calculated by provider if available
generation_id: generationId, generation_id: generationId,
session_token: sessionToken, session_token: sessionToken,
content_html: contentHtmlResult.unwrapOr(undefined), content_html: contentHtmlResult.unwrapOr(undefined),
@ -585,14 +506,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}), }),
(e) => `Failed to update generating status: ${e}` (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()) { if (updateGeneratingResult.isErr()) {
@ -608,13 +521,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
} }
log('Background: Message updated', startTime); 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) { } catch (error) {
handleGenerationError({ handleGenerationError({
error: `Stream processing error: ${error}`, error: `Stream processing error: ${error}`,
@ -656,7 +562,6 @@ export const POST: RequestHandler = async ({ request }) => {
log('Schema validation passed', startTime); log('Schema validation passed', startTime);
const cookie = getSessionCookie(request.headers); const cookie = getSessionCookie(request.headers);
const sessionToken = cookie?.split('.')[0] ?? null; const sessionToken = cookie?.split('.')[0] ?? null;
if (!sessionToken) { if (!sessionToken) {
@ -664,29 +569,37 @@ export const POST: RequestHandler = async ({ request }) => {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
const modelResultPromise = ResultAsync.fromPromise( // Get user API keys
client.query(api.user_enabled_models.get, { const userApiKeysResult = await getUserApiKeys(sessionToken);
provider: Provider.OpenRouter, if (userApiKeysResult.isErr()) {
model_id: args.model_id, log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
session_token: sessionToken, return error(500, 'Failed to get user API keys');
}), }
(e) => `Failed to get model: ${e}`
);
const keyResultPromise = ResultAsync.fromPromise( const userApiKeys = userApiKeysResult.value;
client.query(api.user_keys.get, { const hasAnyKey = Object.values(userApiKeys).some((key) => key);
provider: Provider.OpenRouter,
session_token: sessionToken,
}),
(e) => `Failed to get API key: ${e}`
);
const userSettingsPromise = ResultAsync.fromPromise( if (!hasAnyKey) {
client.query(api.user_settings.get, { log('User has no API keys configured', startTime);
session_token: sessionToken, return error(
}), 400,
(e) => `Failed to get user settings: ${e}` 'No API keys configured. Please add at least one provider API key in settings.'
); );
}
// 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 rulesResultPromise = ResultAsync.fromPromise( const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, { client.query(api.user_rules.all, {
@ -699,7 +612,7 @@ export const POST: RequestHandler = async ({ request }) => {
let conversationId = args.conversation_id; let conversationId = args.conversation_id;
if (!conversationId) { if (!conversationId) {
// technically zod should catch this but just in case // Create new conversation
if (args.message === undefined) { if (args.message === undefined) {
return error(400, 'You must provide a message when creating a new conversation'); return error(400, 'You must provide a message when creating a new conversation');
} }
@ -709,7 +622,7 @@ export const POST: RequestHandler = async ({ request }) => {
content: args.message, content: args.message,
content_html: '', content_html: '',
role: 'user', role: 'user',
images: args.images, attachments: args.attachments,
web_search_enabled: args.web_search_enabled, web_search_enabled: args.web_search_enabled,
session_token: sessionToken, session_token: sessionToken,
}), }),
@ -730,8 +643,8 @@ export const POST: RequestHandler = async ({ request }) => {
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
keyResultPromise,
userMessage: args.message, userMessage: args.message,
modelManager,
}).catch((error) => { }).catch((error) => {
log(`Background title generation error: ${error}`, startTime); log(`Background title generation error: ${error}`, startTime);
}) })
@ -746,8 +659,9 @@ export const POST: RequestHandler = async ({ request }) => {
content: args.message, content: args.message,
session_token: args.session_token, session_token: args.session_token,
model_id: args.model_id, model_id: args.model_id,
reasoning_effort: args.reasoning_effort,
role: 'user', role: 'user',
images: args.images, attachments: args.attachments,
web_search_enabled: args.web_search_enabled, web_search_enabled: args.web_search_enabled,
}), }),
(e) => `Failed to create user message: ${e}` (e) => `Failed to create user message: ${e}`
@ -781,17 +695,17 @@ export const POST: RequestHandler = async ({ request }) => {
const abortController = new AbortController(); const abortController = new AbortController();
generationAbortControllers.set(conversationId, abortController); generationAbortControllers.set(conversationId, abortController);
// Start AI response generation in background - don't await // Start AI response generation in background
waitUntil( waitUntil(
generateAIResponse({ generateAIResponse({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
modelResultPromise, modelId: args.model_id,
keyResultPromise, modelManager,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal, abortSignal: abortController.signal,
reasoningEffort: args.reasoning_effort,
}) })
.catch(async (error) => { .catch(async (error) => {
log(`Background AI response generation error: ${error}`, startTime); log(`Background AI response generation error: ${error}`, startTime);
@ -816,57 +730,6 @@ export const POST: RequestHandler = async ({ request }) => {
return response({ ok: true, conversation_id: conversationId }); 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({ async function handleGenerationError({
error, error,
conversationId, conversationId,
@ -899,38 +762,3 @@ async function handleGenerationError({
log('Error updated', startTime); 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

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
import { CopyButton } from '$lib/components/ui/copy-button'; import { CopyButton } from '$lib/components/ui/copy-button';
import '../../../markdown.css'; import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte'; import MarkdownRenderer from './markdown-renderer.svelte';
import { ImageModal } from '$lib/components/ui/image-modal'; import FilePreview from '$lib/components/ui/file-preview/file-preview.svelte';
import { sanitizeHtml } from '$lib/utils/markdown-it'; import { sanitizeHtml } from '$lib/utils/markdown-it';
import { on } from 'svelte/events'; import { on } from 'svelte/events';
import { isHtmlElement } from '$lib/utils/is'; import { isHtmlElement } from '$lib/utils/is';
@ -19,6 +19,14 @@
import { callGenerateMessage } from '../../api/generate-message/call'; import { callGenerateMessage } from '../../api/generate-message/call';
import * as Icons from '$lib/components/icons'; import * as Icons from '$lib/components/icons';
import { settings } from '$lib/state/settings.svelte'; 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({ const style = tv({
base: 'prose rounded-xl p-2 max-w-full', base: 'prose rounded-xl p-2 max-w-full',
@ -38,20 +46,7 @@
let { message }: Props = $props(); 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() { async function createBranchedConversation() {
const res = await ResultAsync.fromPromise( const res = await ResultAsync.fromPromise(
client.mutation(api.conversations.createBranched, { client.mutation(api.conversations.createBranched, {
@ -86,9 +81,27 @@
await goto(`/chat/${cid}`); 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> </script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)} {#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && message.reasoning?.length === 0 && !message.error)}
<div <div
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })} class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
{@attach (node) => { {@attach (node) => {
@ -106,23 +119,57 @@
}); });
}} }}
> >
{#if message.images && message.images.length > 0} {#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 -->
<div class="mb-2 flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each message.images as image (image.storage_id)} {#each message.images as image (image.storage_id)}
<button <FilePreview
type="button" attachment={{
onclick={() => openImageModal(image.url, image.fileName || 'image')} type: 'image',
class="rounded-lg" url: image.url,
> fileName: image.fileName || 'image',
<img mimeType: image.mimeType || 'image/jpeg',
src={image.url} size: image.size || 0,
alt={image.fileName || 'Uploaded'} storage_id: image.storage_id
class="max-w-xs rounded-lg transition-opacity hover:opacity-80" }}
/> isUserMessage={message.role === 'user'}
</button> compact={true}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if message.reasoning}
<div class="my-8">
<button
type="button"
class="text-muted-foreground flex items-center gap-1 pb-2 text-sm"
aria-label="Toggle reasoning"
onclick={() => (showReasoning = !showReasoning)}
>
<ChevronRightIcon class={cn('inline size-4', { 'rotate-90': showReasoning })} />
{#if message.content.length === 0}
<ShinyText>Reasoning...</ShinyText>
{:else}
<span>Reasoning</span>
{/if}
</button>
{#if showReasoning}
<div class="text-muted-foreground/50 bg-popover relative rounded-lg p-2 text-xs">
{message.reasoning}
</div>
{/if}
</div>
{/if}
<div class={style({ role: message.role })}> <div class={style({ role: message.role })}>
{#if message.error} {#if message.error}
<div class="text-destructive"> <div class="text-destructive">
@ -146,6 +193,58 @@
</svelte:boundary> </svelte:boundary>
{/if} {/if}
</div> </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 <div
class={cn( class={cn(
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0', 'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
@ -188,8 +287,16 @@
{#if message.model_id !== undefined} {#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span> <span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if} {/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} {#if message.web_search_enabled}
<span class="text-muted-foreground text-xs"> Web search enabled </span> <span class="text-muted-foreground text-xs">
<GlobeIcon class="text-primary inline-block size-4 shrink-0" />
</span>
{/if} {/if}
{#if message.cost_usd !== undefined} {#if message.cost_usd !== undefined}
@ -201,11 +308,15 @@
</div> </div>
</div> </div>
{#if message.images && message.images.length > 0}
<ImageModal
bind:open={imageModal.open}
imageUrl={imageModal.imageUrl}
fileName={imageModal.fileName}
/>
{/if} {/if}
{/if}
{#snippet siteIcon({ url }: { url: URL })}
<Avatar src={`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`}>
{#snippet children(avatar)}
<img {...avatar.image} alt={`${url.hostname} site icon`} />
<span {...avatar.fallback}>
<GlobeIcon class="inline-block size-4 shrink-0" />
</span>
{/snippet}
</Avatar>
{/snippet}

View file

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

1
tmp/kepler-ai-sdk Submodule

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