Compare commits
10 commits
cbe926fe8c
...
c77f5fb877
| Author | SHA1 | Date | |
|---|---|---|---|
| c77f5fb877 | |||
| 7434d98fc1 | |||
| bca38fa221 | |||
| 12b4fef96d | |||
| 31d72543b3 | |||
| 071e1016b1 | |||
|
|
f8f6748bec | ||
|
|
a77493c9ef | ||
|
|
7b9595e571 | ||
|
|
a96ba2152b |
72 changed files with 6581 additions and 1176 deletions
|
|
@ -13,4 +13,11 @@ GITHUB_CLIENT_SECRET=
|
|||
GOOGLE_CLIENT_ID=
|
||||
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
42
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
|
|||
.aider*
|
||||
|
||||
src/lib/backend/convex/_generated
|
||||
tmp/
|
||||
|
|
|
|||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"svelte-llm": {
|
||||
"type": "http",
|
||||
"url": "https://svelte-llm.stanislav.garden/mcp/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
README.md
41
README.md
|
|
@ -32,10 +32,10 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
|||
|
||||
### 🤖 **AI & Models**
|
||||
|
||||
- **400+ AI Models** via OpenRouter integration
|
||||
- **Free Tier** with 10 messages using premium models
|
||||
- **Unlimited Free Models** (models ending in `:free`)
|
||||
- **Bring Your Own Key** for unlimited access
|
||||
- **Multiple AI Providers** - OpenAI, Anthropic, Google Gemini, Mistral, Cohere, OpenRouter
|
||||
- **600+ AI Models** across all providers
|
||||
- **Bring Your Own API Keys** - Users must provide their own API keys
|
||||
- **No Usage Limits** - Use any model without restrictions when you have the API key
|
||||
|
||||
### 💬 **Chat Experience**
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
|||
|
||||
- 🔧 Convex Database
|
||||
- 🔐 BetterAuth
|
||||
- 🤖 OpenRouter API
|
||||
- 🤖 Kepler AI SDK (Multi-provider support)
|
||||
- 🦾 Blood, sweat, and tears
|
||||
|
||||
</td>
|
||||
|
|
@ -92,7 +92,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
|||
|
||||
- Node.js 18+
|
||||
- pnpm (recommended)
|
||||
- OpenRouter API key (optional for free tier)
|
||||
- At least one AI provider API key (OpenAI, Anthropic, Gemini, etc.)
|
||||
|
||||
### Installation
|
||||
|
||||
|
|
@ -129,16 +129,28 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
|||
|
||||
## 🎮 Usage
|
||||
|
||||
### Free Tier
|
||||
### Getting Started
|
||||
|
||||
- Sign up and get **10 free messages** with premium models
|
||||
- Use **unlimited free models** (ending in `:free`)
|
||||
- No credit card required
|
||||
1. **Sign up** for a free account
|
||||
2. **Add API Keys** - Go to Settings and add API keys for the providers you want to use:
|
||||
- **OpenAI** - GPT models, DALL-E, Whisper
|
||||
- **Anthropic** - Claude models
|
||||
- **Google Gemini** - Gemini models and vision
|
||||
- **Mistral** - Mistral models and embeddings
|
||||
- **Cohere** - Command models and embeddings
|
||||
- **OpenRouter** - Access to 300+ models
|
||||
3. **Start Chatting** - Select any model from your enabled providers
|
||||
|
||||
### Premium Features
|
||||
### Supported Providers
|
||||
|
||||
- Add your own OpenRouter API key for unlimited access
|
||||
- Access to all 400+ models
|
||||
| Provider | Models | Streaming | Tools | Vision | Embeddings |
|
||||
|----------|---------|-----------|-------|--------|------------|
|
||||
| OpenAI | GPT-4, o3-mini, DALL-E, TTS | ✅ | ✅ | ✅ | ✅ |
|
||||
| Anthropic | Claude 4, Claude 3.5 Sonnet | ✅ | ✅ | ✅ | ❌ |
|
||||
| Google Gemini | Gemini 2.5 Pro, Imagen | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mistral | Mistral Large, Mistral Embed | ✅ | ✅ | ❌ | ✅ |
|
||||
| Cohere | Command A, Command R+ | ✅ | ✅ | ❌ | ✅ |
|
||||
| OpenRouter | 300+ models | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
## 🤝 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/)
|
||||
- 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/)
|
||||
|
||||
---
|
||||
|
|
@ -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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -33,6 +33,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/svelte": "^5.2.4",
|
||||
"@vercel/functions": "^2.2.0",
|
||||
"bits-ui": "^2.8.5",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"convex": "^1.24.8",
|
||||
|
|
@ -44,9 +45,14 @@
|
|||
"globals": "^16.0.0",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
|
||||
"melt": "^0.38.0",
|
||||
"mode-watcher": "^1.0.8",
|
||||
"neverthrow": "^8.2.0",
|
||||
"@anthropic-ai/sdk": "^0.29.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@mistralai/mistralai": "^1.1.0",
|
||||
"cohere-ai": "^7.14.0",
|
||||
"openai": "^5.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
|
@ -58,6 +64,7 @@
|
|||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
|
|
@ -80,11 +87,11 @@
|
|||
"@fontsource-variable/nunito-sans": "^5.2.6",
|
||||
"@fontsource-variable/open-sans": "^5.2.6",
|
||||
"@fontsource/instrument-serif": "^5.2.6",
|
||||
"@keplersystems/kepler-ai-sdk": "file:./tmp/kepler-ai-sdk",
|
||||
"better-auth": "^1.2.9",
|
||||
"convex-helpers": "^0.1.94",
|
||||
"hastscript": "^9.0.1",
|
||||
"markdown-it-async": "^2.2.0",
|
||||
"openai": "^5.3.0",
|
||||
"zod": "^3.25.64"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
pnpm-lock.yaml
generated
100
pnpm-lock.yaml
generated
|
|
@ -47,9 +47,6 @@ importers:
|
|||
markdown-it-async:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
openai:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(ws@8.18.2)(zod@3.25.64)
|
||||
zod:
|
||||
specifier: ^3.25.64
|
||||
version: 3.25.64
|
||||
|
|
@ -99,6 +96,9 @@ importers:
|
|||
'@vercel/functions':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
bits-ui:
|
||||
specifier: ^2.8.5
|
||||
version: 2.8.5(@internationalized/date@3.8.2)(svelte@5.34.1)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
|
|
@ -133,14 +133,17 @@ importers:
|
|||
specifier: ^26.0.0
|
||||
version: 26.1.0
|
||||
melt:
|
||||
specifier: https://pkg.vc/-/@melt-ui/melt@42e572f
|
||||
version: https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1)
|
||||
specifier: ^0.38.0
|
||||
version: 0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1)
|
||||
mode-watcher:
|
||||
specifier: ^1.0.8
|
||||
version: 1.0.8(svelte@5.34.1)
|
||||
neverthrow:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
openai:
|
||||
specifier: ^5.5.1
|
||||
version: 5.5.1(ws@8.18.2)(zod@3.25.64)
|
||||
prettier:
|
||||
specifier: ^3.4.2
|
||||
version: 3.5.3
|
||||
|
|
@ -174,6 +177,9 @@ importers:
|
|||
tailwindcss:
|
||||
specifier: ^4.0.0
|
||||
version: 4.1.10
|
||||
tw-animate-css:
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.8.3
|
||||
|
|
@ -677,6 +683,9 @@ packages:
|
|||
'@iconify/utils@2.3.0':
|
||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -921,6 +930,9 @@ packages:
|
|||
svelte: ^5.0.0
|
||||
vite: ^6.0.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||
|
||||
|
|
@ -1241,6 +1253,13 @@ packages:
|
|||
better-call@1.0.9:
|
||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||
|
||||
bits-ui@2.8.5:
|
||||
resolution: {integrity: sha512-GVVDcmc+mziNNWdzlBviN3HjFAIdEFddQFvTA5cjronMan8PnIhpNhc2+DKL5CYdTbrz6kuyt2YvuvnoWYmovw==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@internationalized/date': ^3.8.1
|
||||
svelte: ^5.33.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
|
|
@ -1602,6 +1621,9 @@ packages:
|
|||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
focus-trap@7.6.5:
|
||||
resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -1919,9 +1941,8 @@ packages:
|
|||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
melt@https://pkg.vc/-/@melt-ui/melt@42e572f:
|
||||
resolution: {tarball: https://pkg.vc/-/@melt-ui/melt@42e572f}
|
||||
version: 0.35.0
|
||||
melt@0.38.0:
|
||||
resolution: {integrity: sha512-fVUZdZSYUeYw4uwZkY+KoxmbVVMY14qEA21wjIR0ELAMlWc/eSeG0JcZn0wSB7dB1JdWo7KsVrZssieiQWSp5A==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.6.0
|
||||
svelte: ^5.30.1
|
||||
|
|
@ -1997,11 +2018,6 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.5:
|
||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
nanostores@0.11.4:
|
||||
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -2022,8 +2038,8 @@ packages:
|
|||
oniguruma-to-es@4.3.3:
|
||||
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
|
||||
|
||||
openai@5.3.0:
|
||||
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
|
||||
openai@5.5.1:
|
||||
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
|
|
@ -2420,6 +2436,12 @@ packages:
|
|||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
svelte-toolbelt@0.9.2:
|
||||
resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
|
||||
svelte@5.34.1:
|
||||
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2427,6 +2449,9 @@ packages:
|
|||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
tailwind-merge@3.0.2:
|
||||
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||
|
||||
|
|
@ -2514,6 +2539,9 @@ packages:
|
|||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tw-animate-css@1.3.4:
|
||||
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -3102,6 +3130,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@internationalized/date@3.8.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.17
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.2
|
||||
|
|
@ -3347,6 +3379,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -3695,6 +3731,17 @@ snapshots:
|
|||
set-cookie-parser: 2.7.1
|
||||
uncrypto: 0.1.3
|
||||
|
||||
bits-ui@2.8.5(@internationalized/date@3.8.2)(svelte@5.34.1):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.1
|
||||
'@floating-ui/dom': 1.7.1
|
||||
'@internationalized/date': 3.8.2
|
||||
esm-env: 1.2.2
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
svelte: 5.34.1
|
||||
svelte-toolbelt: 0.9.2(svelte@5.34.1)
|
||||
tabbable: 6.2.0
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
|
@ -4072,6 +4119,10 @@ snapshots:
|
|||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
focus-trap@7.6.5:
|
||||
dependencies:
|
||||
tabbable: 6.2.0
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -4389,12 +4440,12 @@ snapshots:
|
|||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
melt@https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1):
|
||||
melt@0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.1
|
||||
dequal: 2.0.3
|
||||
focus-trap: 7.6.5
|
||||
jest-axe: 9.0.0
|
||||
nanoid: 5.1.5
|
||||
runed: 0.23.4(svelte@5.34.1)
|
||||
svelte: 5.34.1
|
||||
|
||||
|
|
@ -4461,8 +4512,6 @@ snapshots:
|
|||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.5: {}
|
||||
|
||||
nanostores@0.11.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
|
@ -4481,7 +4530,7 @@ snapshots:
|
|||
regex: 6.0.1
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
|
||||
openai@5.5.1(ws@8.18.2)(zod@3.25.64):
|
||||
optionalDependencies:
|
||||
ws: 8.18.2
|
||||
zod: 3.25.64
|
||||
|
|
@ -4821,6 +4870,13 @@ snapshots:
|
|||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte-toolbelt@0.9.2(svelte@5.34.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.28.0(svelte@5.34.1)
|
||||
style-to-object: 1.0.9
|
||||
svelte: 5.34.1
|
||||
|
||||
svelte@5.34.1:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
|
@ -4840,6 +4896,8 @@ snapshots:
|
|||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tailwind-merge@3.0.2: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
|
@ -4909,6 +4967,8 @@ snapshots:
|
|||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.3.4: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
|
|||
12
src/app.css
12
src/app.css
|
|
@ -4,6 +4,8 @@
|
|||
@import '@fontsource-variable/nunito-sans';
|
||||
@import '@fontsource/instrument-serif';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
|
|
@ -341,6 +343,16 @@
|
|||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Large modal variant for file previews */
|
||||
.modal-large .modal-box {
|
||||
@apply max-w-[90vw] max-h-[90vh] p-4;
|
||||
}
|
||||
|
||||
/* Extra large modal for maximum viewing space */
|
||||
.modal-xlarge .modal-box {
|
||||
@apply max-w-[95vw] max-h-[95vh] p-2;
|
||||
}
|
||||
|
||||
.modal-top {
|
||||
@apply place-items-start;
|
||||
|
||||
|
|
|
|||
|
|
@ -96,12 +96,15 @@ export const createAndAddMessage = mutation({
|
|||
role: messageRoleValidator,
|
||||
session_token: v.string(),
|
||||
web_search_enabled: v.optional(v.boolean()),
|
||||
images: v.optional(
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
|
||||
url: v.string(),
|
||||
storage_id: v.string(),
|
||||
fileName: v.optional(v.string()),
|
||||
fileName: v.string(),
|
||||
mimeType: v.string(),
|
||||
size: v.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
|
|
@ -137,7 +140,7 @@ export const createAndAddMessage = mutation({
|
|||
conversation_id: conversationId,
|
||||
session_token: args.session_token,
|
||||
web_search_enabled: args.web_search_enabled,
|
||||
images: args.images,
|
||||
attachments: args.attachments,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { v } from 'convex/values';
|
|||
import { api } from './_generated/api';
|
||||
import { type Id } from './_generated/dataModel';
|
||||
import { query } from './_generated/server';
|
||||
import { messageRoleValidator, providerValidator } from './schema';
|
||||
import { messageRoleValidator, providerValidator, reasoningEffortValidator } from './schema';
|
||||
import { mutation } from './functions';
|
||||
|
||||
export const getAllFromConversation = query({
|
||||
|
|
@ -47,13 +47,17 @@ export const create = mutation({
|
|||
provider: v.optional(providerValidator),
|
||||
token_count: v.optional(v.number()),
|
||||
web_search_enabled: v.optional(v.boolean()),
|
||||
// Optional image attachments
|
||||
images: v.optional(
|
||||
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||
// Optional attachments
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
|
||||
url: v.string(),
|
||||
storage_id: v.string(),
|
||||
fileName: v.optional(v.string()),
|
||||
fileName: v.string(),
|
||||
mimeType: v.string(),
|
||||
size: v.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
|
|
@ -94,8 +98,9 @@ export const create = mutation({
|
|||
provider: args.provider,
|
||||
token_count: args.token_count,
|
||||
web_search_enabled: args.web_search_enabled,
|
||||
// Optional image attachments
|
||||
images: args.images,
|
||||
reasoning_effort: args.reasoning_effort,
|
||||
// Optional attachments
|
||||
attachments: args.attachments,
|
||||
}),
|
||||
ctx.db.patch(args.conversation_id as Id<'conversations'>, {
|
||||
generating: true,
|
||||
|
|
@ -112,7 +117,11 @@ export const updateContent = mutation({
|
|||
session_token: v.string(),
|
||||
message_id: v.string(),
|
||||
content: v.string(),
|
||||
reasoning: v.optional(v.string()),
|
||||
content_html: v.optional(v.string()),
|
||||
generation_id: v.optional(v.string()),
|
||||
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||
|
|
@ -131,7 +140,11 @@ export const updateContent = mutation({
|
|||
|
||||
await ctx.db.patch(message._id, {
|
||||
content: args.content,
|
||||
reasoning: args.reasoning,
|
||||
content_html: args.content_html,
|
||||
generation_id: args.generation_id,
|
||||
annotations: args.annotations,
|
||||
reasoning_effort: args.reasoning_effort,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export const messageRoleValidator = v.union(
|
|||
v.literal('assistant'),
|
||||
v.literal('system')
|
||||
);
|
||||
export const reasoningEffortValidator = v.union(
|
||||
v.literal('low'),
|
||||
v.literal('medium'),
|
||||
v.literal('high')
|
||||
);
|
||||
|
||||
export type MessageRole = Infer<typeof messageRoleValidator>;
|
||||
|
||||
|
|
@ -17,7 +22,6 @@ export default defineSchema({
|
|||
user_settings: defineTable({
|
||||
user_id: v.string(),
|
||||
privacy_mode: v.boolean(),
|
||||
free_messages_used: v.optional(v.number()),
|
||||
}).index('by_user', ['user_id']),
|
||||
user_keys: defineTable({
|
||||
user_id: v.string(),
|
||||
|
|
@ -31,7 +35,8 @@ export default defineSchema({
|
|||
provider: providerValidator,
|
||||
/** Different providers may use different ids for the same model */
|
||||
model_id: v.string(),
|
||||
pinned: v.union(v.number(), v.null()),
|
||||
// null is just here for compat we treat null as true
|
||||
pinned: v.optional(v.union(v.boolean(), v.null())),
|
||||
})
|
||||
.index('by_user', ['user_id'])
|
||||
.index('by_model_provider', ['model_id', 'provider'])
|
||||
|
|
@ -61,23 +66,29 @@ export default defineSchema({
|
|||
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
||||
content: v.string(),
|
||||
content_html: v.optional(v.string()),
|
||||
reasoning: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
// Optional, coming from SK API route
|
||||
model_id: v.optional(v.string()),
|
||||
provider: v.optional(providerValidator),
|
||||
token_count: v.optional(v.number()),
|
||||
// Optional image attachments
|
||||
images: v.optional(
|
||||
// Optional attachments
|
||||
attachments: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
type: v.union(v.literal('image'), v.literal('video'), v.literal('audio'), v.literal('document')),
|
||||
url: v.string(),
|
||||
storage_id: v.string(),
|
||||
fileName: v.optional(v.string()),
|
||||
fileName: v.string(),
|
||||
mimeType: v.string(),
|
||||
size: v.number(),
|
||||
})
|
||||
)
|
||||
),
|
||||
cost_usd: v.optional(v.number()),
|
||||
generation_id: v.optional(v.string()),
|
||||
web_search_enabled: v.optional(v.boolean()),
|
||||
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
|
||||
}).index('by_conversation', ['conversation_id']),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,20 +101,50 @@ export const set = mutation({
|
|||
)
|
||||
.first();
|
||||
|
||||
if (args.enabled && existing) return; // nothing to do here
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
} else {
|
||||
if (args.enabled) {
|
||||
// Enable model: insert if not exists
|
||||
if (!existing) {
|
||||
await ctx.db.insert('user_enabled_models', {
|
||||
...object.pick(args, ['provider', 'model_id']),
|
||||
user_id: session.userId,
|
||||
pinned: null,
|
||||
pinned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Disable model: delete if exists
|
||||
if (existing) {
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const toggle_pinned = mutation({
|
||||
args: {
|
||||
session_token: v.string(),
|
||||
enabled_model_id: v.id('user_enabled_models'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||
sessionToken: args.session_token,
|
||||
});
|
||||
|
||||
if (!session) throw new Error('Invalid session token');
|
||||
|
||||
const model = await ctx.db.get(args.enabled_model_id);
|
||||
|
||||
if (!model) throw new Error('Model not found');
|
||||
|
||||
await ctx.db.patch(args.enabled_model_id, {
|
||||
pinned: !isPinned(model),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function isPinned(model: Doc<'user_enabled_models'>) {
|
||||
return model.pinned === null || model.pinned;
|
||||
}
|
||||
|
||||
export const enable_initial = mutation({
|
||||
args: {
|
||||
session_token: v.string(),
|
||||
|
|
@ -150,7 +180,7 @@ export const enable_initial = mutation({
|
|||
user_id: session.userId,
|
||||
provider: Provider.OpenRouter,
|
||||
model_id: model,
|
||||
pinned: null,
|
||||
pinned: true,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,26 +89,6 @@ export const set = mutation({
|
|||
await ctx.db.replace(existing._id, userKey);
|
||||
} else {
|
||||
await ctx.db.insert('user_keys', userKey);
|
||||
|
||||
if (args.provider === Provider.OpenRouter) {
|
||||
const defaultModels = [
|
||||
'google/gemini-2.5-flash',
|
||||
'anthropic/claude-sonnet-4',
|
||||
'openai/o3-mini',
|
||||
'deepseek/deepseek-chat-v3-0324:free',
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
defaultModels.map((model) =>
|
||||
ctx.db.insert('user_enabled_models', {
|
||||
user_id: session.userId,
|
||||
provider: Provider.OpenRouter,
|
||||
model_id: model,
|
||||
pinned: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
args: {
|
||||
privacy_mode: v.boolean(),
|
||||
|
|
@ -86,7 +51,6 @@ export const set = mutation({
|
|||
await ctx.db.insert('user_settings', {
|
||||
user_id: s.userId,
|
||||
privacy_mode: args.privacy_mode,
|
||||
free_messages_used: 0,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
|
@ -105,7 +69,6 @@ export const create = mutation({
|
|||
await ctx.db.insert('user_settings', {
|
||||
user_id: args.user_id,
|
||||
privacy_mode: false,
|
||||
free_messages_used: 0,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<p
|
||||
style:--shimmer-width="{shimmerWidth}px"
|
||||
class={cn(
|
||||
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
|
||||
'max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
|
||||
|
||||
// Shimmer effect
|
||||
'animate-shimmer [background-size:var(--shimmer-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
||||
|
|
|
|||
3
src/lib/components/model-picker/index.ts
Normal file
3
src/lib/components/model-picker/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import ModelPicker from './model-picker.svelte';
|
||||
|
||||
export { ModelPicker };
|
||||
640
src/lib/components/model-picker/model-picker.svelte
Normal file
640
src/lib/components/model-picker/model-picker.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
49
src/lib/components/ui/dropdown-menu/index.ts
Normal file
49
src/lib/components/ui/dropdown-menu/index.ts
Normal 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,
|
||||
};
|
||||
512
src/lib/components/ui/file-preview/audio-preview.svelte
Normal file
512
src/lib/components/ui/file-preview/audio-preview.svelte
Normal 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>
|
||||
379
src/lib/components/ui/file-preview/document-preview.svelte
Normal file
379
src/lib/components/ui/file-preview/document-preview.svelte
Normal 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>
|
||||
97
src/lib/components/ui/file-preview/file-preview.svelte
Normal file
97
src/lib/components/ui/file-preview/file-preview.svelte
Normal 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>
|
||||
149
src/lib/components/ui/file-preview/image-preview.svelte
Normal file
149
src/lib/components/ui/file-preview/image-preview.svelte
Normal 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>
|
||||
321
src/lib/components/ui/file-preview/video-preview.svelte
Normal file
321
src/lib/components/ui/file-preview/video-preview.svelte
Normal 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>
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
primary: 'bg-primary text-primary-foreground',
|
||||
},
|
||||
size: {
|
||||
xs: 'min-w-5 gap-1.5 p-0.5 px-0.5 text-xs',
|
||||
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
|
||||
default: 'min-w-8 gap-1.5 p-1 px-2',
|
||||
lg: 'min-w-9 gap-2 p-1 px-3 text-lg',
|
||||
|
|
|
|||
16
src/lib/components/ui/popover/index.ts
Normal file
16
src/lib/components/ui/popover/index.ts
Normal 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,
|
||||
};
|
||||
28
src/lib/components/ui/popover/popover-content.svelte
Normal file
28
src/lib/components/ui/popover/popover-content.svelte
Normal 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>
|
||||
16
src/lib/components/ui/popover/popover-trigger.svelte
Normal file
16
src/lib/components/ui/popover/popover-trigger.svelte
Normal 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}
|
||||
/>
|
||||
4
src/lib/hooks/is-firefox.svelte.ts
Normal file
4
src/lib/hooks/is-firefox.svelte.ts
Normal 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');
|
||||
160
src/lib/services/model-loader.server.ts
Normal file
160
src/lib/services/model-loader.server.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
130
src/lib/services/model-manager.ts
Normal file
130
src/lib/services/model-manager.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -26,7 +26,7 @@ type PersistedObjOptions<T> = {
|
|||
syncTabs?: boolean;
|
||||
};
|
||||
|
||||
export function createPersistedObj<T extends object>(
|
||||
export function createPersistedObj<T extends Record<string, unknown>>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
options: PersistedObjOptions<T> = {}
|
||||
|
|
@ -37,7 +37,7 @@ export function createPersistedObj<T extends object>(
|
|||
syncTabs = true,
|
||||
} = options;
|
||||
|
||||
let current = initialValue;
|
||||
let current: Record<string, unknown> = initialValue;
|
||||
let storage: Storage | undefined;
|
||||
let subscribe: VoidFunction | undefined;
|
||||
let version = $state(0);
|
||||
|
|
@ -47,7 +47,18 @@ export function createPersistedObj<T extends object>(
|
|||
const existingValue = storage.getItem(key);
|
||||
if (existingValue !== null) {
|
||||
const deserialized = deserialize(existingValue);
|
||||
if (deserialized) current = deserialized;
|
||||
|
||||
if (deserialized) {
|
||||
// handle keys that were added at a later point in time
|
||||
for (const key of Object.keys(initialValue)) {
|
||||
const initialKeyValue = deserialized[key];
|
||||
if (initialKeyValue === undefined) {
|
||||
deserialized[key] = initialValue[key];
|
||||
}
|
||||
}
|
||||
|
||||
current = deserialized;
|
||||
}
|
||||
} else {
|
||||
serialize(initialValue);
|
||||
}
|
||||
|
|
@ -66,7 +77,7 @@ export function createPersistedObj<T extends object>(
|
|||
version += 1;
|
||||
}
|
||||
|
||||
function deserialize(value: string): T | undefined {
|
||||
function deserialize(value: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
return serializer.deserialize(value);
|
||||
} catch (error) {
|
||||
|
|
@ -75,7 +86,7 @@ export function createPersistedObj<T extends object>(
|
|||
}
|
||||
}
|
||||
|
||||
function serialize(value: T | undefined): void {
|
||||
function serialize(value: Record<string, unknown> | undefined): void {
|
||||
try {
|
||||
if (value != undefined) {
|
||||
storage?.setItem(key, serializer.serialize(value));
|
||||
|
|
|
|||
23
src/lib/state/last-chat.svelte.ts
Normal file
23
src/lib/state/last-chat.svelte.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
import { page } from '$app/state';
|
||||
import { api } from '$lib/backend/convex/_generated/api';
|
||||
import { getModelKey } from '$lib/backend/convex/user_enabled_models';
|
||||
import type { ProviderModelMap } from '$lib/backend/models/all';
|
||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||
import { createInit } from '$lib/spells/create-init.svelte';
|
||||
import { Provider } from '$lib/types';
|
||||
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
|
||||
import { watch } from 'runed';
|
||||
import { session } from './session.svelte';
|
||||
|
||||
export interface ModelWithEnabledStatus extends ModelInfo {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class Models {
|
||||
enabled = $state({} as Record<string, unknown>);
|
||||
|
||||
|
|
@ -23,13 +27,103 @@ export class Models {
|
|||
);
|
||||
});
|
||||
|
||||
from<P extends Provider>(provider: Provider) {
|
||||
return page.data.models[provider].map((m: { id: string }) => {
|
||||
return {
|
||||
...m,
|
||||
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined,
|
||||
};
|
||||
}) as Array<ProviderModelMap[P] & { enabled: boolean }>;
|
||||
/**
|
||||
* Get models from a specific provider with enabled status
|
||||
*/
|
||||
from(provider: Provider): ModelWithEnabledStatus[] {
|
||||
const providerModels = page.data.models[provider] || [];
|
||||
return providerModels.map((model: ModelInfo) => ({
|
||||
...model,
|
||||
enabled: this.enabled[getModelKey({ provider, model_id: model.id })] !== undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models from all providers with enabled status
|
||||
*/
|
||||
all(): ModelWithEnabledStatus[] {
|
||||
const allModels: ModelWithEnabledStatus[] = [];
|
||||
const availableProviders = Object.keys(page.data.models || {}) as Provider[];
|
||||
|
||||
for (const provider of availableProviders) {
|
||||
const providerModels = this.from(provider);
|
||||
allModels.push(...providerModels);
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models that support specific capabilities
|
||||
*/
|
||||
withCapability(capability: keyof ModelInfo['capabilities']): ModelWithEnabledStatus[] {
|
||||
return this.all().filter(model => model.capabilities[capability]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled models from all providers
|
||||
*/
|
||||
enabledModels(): ModelWithEnabledStatus[] {
|
||||
return this.all().filter(model => model.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled models from a specific provider
|
||||
*/
|
||||
enabledFrom(provider: Provider): ModelWithEnabledStatus[] {
|
||||
return this.from(provider).filter(model => model.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers (providers that have models loaded)
|
||||
*/
|
||||
availableProviders(): Provider[] {
|
||||
const models = page.data.models || {};
|
||||
return Object.keys(models).filter(provider =>
|
||||
models[provider as Provider] && models[provider as Provider].length > 0
|
||||
) as Provider[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider has models loaded
|
||||
*/
|
||||
hasProvider(provider: Provider): boolean {
|
||||
const models = page.data.models[provider];
|
||||
return Array.isArray(models) && models.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models across all providers
|
||||
*/
|
||||
search(query: string): ModelWithEnabledStatus[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return this.all().filter(model =>
|
||||
model.name.toLowerCase().includes(lowerQuery) ||
|
||||
model.id.toLowerCase().includes(lowerQuery) ||
|
||||
model.description?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models sorted by preference (enabled first, then by provider order)
|
||||
*/
|
||||
sorted(): ModelWithEnabledStatus[] {
|
||||
return this.all().sort((a, b) => {
|
||||
// Enabled models first
|
||||
if (a.enabled && !b.enabled) return -1;
|
||||
if (!a.enabled && b.enabled) return 1;
|
||||
|
||||
// Then by provider order
|
||||
const providerOrder = Object.values(Provider);
|
||||
const aProviderIndex = providerOrder.indexOf(a.provider as Provider);
|
||||
const bProviderIndex = providerOrder.indexOf(b.provider as Provider);
|
||||
if (aProviderIndex !== bProviderIndex) {
|
||||
return aProviderIndex - bProviderIndex;
|
||||
}
|
||||
|
||||
// Finally by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ import { createPersistedObj } from '$lib/spells/persisted-obj.svelte';
|
|||
export const settings = createPersistedObj('settings', {
|
||||
modelId: undefined as string | undefined,
|
||||
webSearchEnabled: false,
|
||||
reasoningEffort: 'low' as 'low' | 'medium' | 'high',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const Provider = {
|
||||
OpenRouter: 'openrouter',
|
||||
HuggingFace: 'huggingface',
|
||||
OpenAI: 'openai',
|
||||
Anthropic: 'anthropic',
|
||||
Gemini: 'gemini',
|
||||
Mistral: 'mistral',
|
||||
Cohere: 'cohere',
|
||||
OpenRouter: 'openrouter',
|
||||
} as const;
|
||||
|
||||
export type Provider = (typeof Provider)[keyof typeof Provider];
|
||||
|
|
@ -11,6 +15,62 @@ export type ProviderMeta = {
|
|||
title: string;
|
||||
link: 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-...',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
40
src/lib/utils/attachment-manager.ts
Normal file
40
src/lib/utils/attachment-manager.ts
Normal 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
333
src/lib/utils/casing.ts
Normal 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
36
src/lib/utils/file.ts
Normal 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() || '';
|
||||
}
|
||||
25
src/lib/utils/is-letter.ts
Normal file
25
src/lib/utils/is-letter.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
return model.architecture.input_modalities.includes('image');
|
||||
export function supportsImages(model: ModelInfo): boolean {
|
||||
return model.capabilities.vision;
|
||||
}
|
||||
|
||||
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
|
||||
return models.filter(supportsImages);
|
||||
export function supportsReasoning(model: ModelInfo): boolean {
|
||||
return model.capabilities.reasoning;
|
||||
}
|
||||
|
||||
export function supportsStreaming(model: ModelInfo): boolean {
|
||||
return model.capabilities.streaming;
|
||||
}
|
||||
|
||||
export function supportsToolCalls(model: ModelInfo): boolean {
|
||||
return model.capabilities.functionCalling;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,61 @@
|
|||
import { Result, ResultAsync } from 'neverthrow';
|
||||
import { Provider, PROVIDER_META } from '$lib/types';
|
||||
|
||||
export type OpenRouterApiKeyData = {
|
||||
export type ProviderApiKeyData = {
|
||||
label: string;
|
||||
usage: number;
|
||||
is_free_tier: boolean;
|
||||
is_provisioning_key: boolean;
|
||||
limit: number;
|
||||
limit_remaining: number;
|
||||
usage?: number;
|
||||
is_free_tier?: boolean;
|
||||
is_provisioning_key?: boolean;
|
||||
limit?: number;
|
||||
limit_remaining?: number;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export const OpenRouter = {
|
||||
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => {
|
||||
export const ProviderUtils = {
|
||||
/**
|
||||
* 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(
|
||||
(async () => {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/key', {
|
||||
const response = await fetch('/api/validate-key', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'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();
|
||||
|
||||
if (!data) throw new Error('No info returned for api key');
|
||||
|
||||
return data as OpenRouterApiKeyData;
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
})(),
|
||||
(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);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
|
||||
import { Provider } from '$lib/types';
|
||||
import { loadUserModels, loadGuestModels } from '$lib/services/model-loader.server';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
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 {
|
||||
session,
|
||||
models: {
|
||||
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
|
||||
},
|
||||
models,
|
||||
};
|
||||
};
|
||||
|
||||
// Makes caching easier, and tbf, we don't need SSR anyways here
|
||||
// Enable SSR for better performance
|
||||
export const ssr = true;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,19 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { MetaTags } from 'svelte-meta-tags';
|
||||
import { page } from '$app/state';
|
||||
import { setupLastChat } from '$lib/state/last-chat.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
setupConvex(PUBLIC_CONVEX_URL);
|
||||
const lastChat = setupLastChat();
|
||||
models.init();
|
||||
|
||||
$effect(() => {
|
||||
if (page.url.pathname.startsWith('/chat')) {
|
||||
lastChat.current = page.params?.id ?? null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<MetaTags
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { session } from '$lib/state/session.svelte.js';
|
||||
import { api } from '$lib/backend/convex/_generated/api.js';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
import { useLastChat } from '$lib/state/last-chat.svelte.js';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
|
|
@ -56,6 +57,14 @@
|
|||
name: 'Search Messages',
|
||||
keys: [cmdOrCtrl, 'K'],
|
||||
},
|
||||
{
|
||||
name: 'Scroll to bottom',
|
||||
keys: [cmdOrCtrl, 'D'],
|
||||
},
|
||||
{
|
||||
name: 'Open Model Picker',
|
||||
keys: [cmdOrCtrl, 'Shift', 'M'],
|
||||
},
|
||||
];
|
||||
|
||||
async function signOut() {
|
||||
|
|
@ -63,11 +72,15 @@
|
|||
|
||||
await goto('/login');
|
||||
}
|
||||
|
||||
const lastChat = useLastChat();
|
||||
|
||||
const backToChat = $derived(lastChat.current ? `/chat/${lastChat.current}` : '/chat');
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-[1200px] space-y-8 pt-6 pb-24">
|
||||
<header class="flex place-items-center justify-between px-4">
|
||||
<Button href="/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" />
|
||||
Back to Chat
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { Provider, type ProviderMeta } from '$lib/types';
|
||||
import { Provider, PROVIDER_META } from '$lib/types';
|
||||
import ProviderCard from './provider-card.svelte';
|
||||
|
||||
const allProviders = Object.values(Provider);
|
||||
|
||||
const providersMeta: Record<Provider, ProviderMeta> = {
|
||||
[Provider.OpenRouter]: {
|
||||
title: 'OpenRouter',
|
||||
link: 'https://openrouter.ai/settings/keys',
|
||||
description: 'API Key for OpenRouter.',
|
||||
models: ['a shit ton'],
|
||||
placeholder: 'sk-or-...',
|
||||
},
|
||||
[Provider.HuggingFace]: {
|
||||
title: 'HuggingFace',
|
||||
link: 'https://huggingface.co/settings/tokens',
|
||||
description: 'API Key for HuggingFace, for open-source models.',
|
||||
placeholder: 'hf_...',
|
||||
},
|
||||
[Provider.OpenAI]: {
|
||||
title: 'OpenAI',
|
||||
link: 'https://platform.openai.com/account/api-keys',
|
||||
description: 'API Key for OpenAI.',
|
||||
models: ['gpt-3.5-turbo', 'gpt-4'],
|
||||
placeholder: 'sk-...',
|
||||
},
|
||||
[Provider.Anthropic]: {
|
||||
title: 'Anthropic',
|
||||
link: 'https://console.anthropic.com/account/api-keys',
|
||||
description: 'API Key for Anthropic.',
|
||||
models: [
|
||||
'Claude 3.5 Sonnet',
|
||||
'Claude 3.7 Sonnet',
|
||||
'Claude 3.7 Sonnet (Reasoning)',
|
||||
'Claude 4 Opus',
|
||||
'Claude 4 Sonnet',
|
||||
'Claude 4 Sonnet (Reasoning)',
|
||||
],
|
||||
placeholder: 'sk-ant-...',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -49,17 +12,13 @@
|
|||
<div>
|
||||
<h1 class="text-2xl font-bold">API Keys</h1>
|
||||
<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
|
||||
towards your monthly limits.
|
||||
Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4">
|
||||
{#each allProviders as provider (provider)}
|
||||
<!-- only do OpenRouter for now -->
|
||||
{#if provider === Provider.OpenRouter}
|
||||
{@const meta = providersMeta[provider]}
|
||||
{@const meta = PROVIDER_META[provider]}
|
||||
<ProviderCard {provider} {meta} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
import { useConvexClient } from 'convex-svelte';
|
||||
import { ResultAsync } from 'neverthrow';
|
||||
import { resource } from 'runed';
|
||||
import * as providers from '$lib/utils/providers';
|
||||
import { ProviderUtils } from '$lib/utils/providers';
|
||||
|
||||
type Props = {
|
||||
provider: Provider;
|
||||
|
|
@ -65,11 +65,8 @@
|
|||
async (key) => {
|
||||
if (!key) return null;
|
||||
|
||||
if (provider === Provider.OpenRouter) {
|
||||
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null);
|
||||
}
|
||||
|
||||
return null;
|
||||
const result = await ProviderUtils.validateApiKey(provider, key);
|
||||
return result.unwrapOr(null);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
@ -99,11 +96,17 @@
|
|||
{#if apiKeyInfoResource.loading}
|
||||
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
|
||||
{:else if apiKeyInfoResource.current}
|
||||
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined && apiKeyInfoResource.current.usage !== null && apiKeyInfoResource.current.limit_remaining !== null}
|
||||
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
|
||||
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed(
|
||||
${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
|
||||
3
|
||||
)} remaining
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
|
||||
✅ API key is valid
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500"
|
||||
|
|
|
|||
|
|
@ -5,65 +5,86 @@
|
|||
import { Search } from '$lib/components/ui/search';
|
||||
import { models } from '$lib/state/models.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 { cn } from '$lib/utils/utils';
|
||||
import { Toggle } from 'melt/builders';
|
||||
import PlusIcon from '~icons/lucide/plus';
|
||||
import XIcon from '~icons/lucide/x';
|
||||
import ModelCard from './model-card.svelte';
|
||||
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
|
||||
|
||||
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||
provider: Provider.OpenRouter,
|
||||
// Get all user's API keys to determine which providers are available
|
||||
const userKeysQuery = useCachedQuery(api.user_keys.all, {
|
||||
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 selectedProvider = $state<Provider | 'all'>('all');
|
||||
|
||||
const openRouterToggle = new Toggle({
|
||||
value: true,
|
||||
// TODO: enable this if and when when we use multiple providers
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const freeModelsToggle = new Toggle({
|
||||
// Filter toggles
|
||||
const reasoningModelsToggle = new Toggle({
|
||||
value: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
let initiallyEnabled = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
if (Object.keys(models.enabled).length && initiallyEnabled.length === 0) {
|
||||
initiallyEnabled = models
|
||||
.from(Provider.OpenRouter)
|
||||
.filter((m) => m.enabled)
|
||||
.map((m) => m.id);
|
||||
const imageModelsToggle = new Toggle({
|
||||
value: false,
|
||||
});
|
||||
|
||||
const streamingModelsToggle = new Toggle({
|
||||
value: false,
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
const openRouterModels = $derived(
|
||||
fuzzysearch({
|
||||
haystack: models.from(Provider.OpenRouter).filter((m) => {
|
||||
if (!freeModelsToggle.value) return true;
|
||||
if (imageModelsToggle.value) {
|
||||
modelList = modelList.filter(m => m.capabilities.vision);
|
||||
}
|
||||
|
||||
if (m.pricing.prompt === '0') return true;
|
||||
if (streamingModelsToggle.value) {
|
||||
modelList = modelList.filter(m => m.capabilities.streaming);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
// Apply text search
|
||||
if (search) {
|
||||
modelList = fuzzysearch({
|
||||
haystack: modelList,
|
||||
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;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: enabled first, then by name
|
||||
return modelList.sort((a, b) => {
|
||||
if (a.enabled && !b.enabled) return -1;
|
||||
if (!a.enabled && b.enabled) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
});
|
||||
|
||||
const hasAnyApiKeys = $derived(availableProviders.length > 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -75,58 +96,125 @@
|
|||
Choose which models appear in your model selector. This won't affect existing conversations.
|
||||
</h2>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
{#if !hasAnyApiKeys}
|
||||
<div class="mt-8 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<h3 class="font-semibold text-yellow-800">No API Keys Configured</h3>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
You need to add API keys for at least one provider to see and manage models.
|
||||
<a href="/account/api-keys" class="underline hover:text-yellow-900">Go to API Keys Settings</a>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-6 space-y-4">
|
||||
<!-- Search -->
|
||||
<Search bind:value={search} placeholder="Search models" />
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
{...openRouterToggle.trigger}
|
||||
aria-label="OpenRouter"
|
||||
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
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>
|
||||
|
||||
{#if openRouterModels.length > 0}
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">OpenRouter</h3>
|
||||
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class={cn('flex flex-col gap-4 overflow-hidden', {
|
||||
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
|
||||
})}
|
||||
<!-- Provider and filter tabs -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Provider selector -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => selectedProvider = 'all'}
|
||||
class={cn(
|
||||
"px-3 py-1 rounded-full text-sm transition-all",
|
||||
selectedProvider === 'all'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
)}
|
||||
>
|
||||
{#each openRouterModels as model (model.id)}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
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
|
||||
provider={Provider.OpenRouter}
|
||||
provider={model.provider as Provider}
|
||||
{model}
|
||||
enabled={model.enabled}
|
||||
disabled={!hasOpenRouterKey}
|
||||
disabled={false}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !hasOpenRouterKey}
|
||||
<div
|
||||
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
|
||||
>
|
||||
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { Provider } from '$lib/types';
|
||||
import { PROVIDER_META } from '$lib/types';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
|
|
@ -7,16 +8,16 @@
|
|||
import { session } from '$lib/state/session.svelte.js';
|
||||
import { ResultAsync } from 'neverthrow';
|
||||
import { getFirstSentence } from '$lib/utils/strings';
|
||||
|
||||
type Model = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
import type { ModelInfo } from '@keplersystems/kepler-ai-sdk';
|
||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||
import EyeIcon from '~icons/lucide/eye';
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import ZapIcon from '~icons/lucide/zap';
|
||||
import CpuIcon from '~icons/lucide/cpu';
|
||||
|
||||
type Props = {
|
||||
provider: Provider;
|
||||
model: Model;
|
||||
model: ModelInfo;
|
||||
enabled?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
|
@ -24,15 +25,23 @@
|
|||
let { provider, model, enabled = false, disabled = false }: Props = $props();
|
||||
|
||||
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);
|
||||
|
||||
async function toggleEnabled(v: boolean) {
|
||||
console.log('toggleEnabled called:', { provider, model_id: model.id, enabled: v });
|
||||
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(
|
||||
client.mutation(api.user_enabled_models.set, {
|
||||
provider,
|
||||
|
|
@ -43,31 +52,138 @@
|
|||
(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>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<Card.Title>{model.name}</Card.Title>
|
||||
<span class="text-muted-foreground hidden text-xs xl:block">{model.id}</span>
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-secondary text-secondary-foreground">
|
||||
{providerMeta.title}
|
||||
</span>
|
||||
</div>
|
||||
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
||||
<span class="text-muted-foreground text-xs">{model.id}</span>
|
||||
</div>
|
||||
<Card.Description
|
||||
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
|
||||
>
|
||||
<Switch bind:value={enabled} onValueChange={toggleEnabled} {disabled} />
|
||||
</div>
|
||||
|
||||
{#if model.description}
|
||||
<Card.Description>
|
||||
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
|
||||
</Card.Description>
|
||||
{#if shortDescription !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground w-fit text-start text-xs"
|
||||
class="text-muted-foreground w-fit text-start text-xs hover:text-foreground transition-colors"
|
||||
onclick={() => (showMore = !showMore)}
|
||||
{disabled}
|
||||
>
|
||||
{showMore ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Capabilities badges -->
|
||||
<div class="flex place-items-center gap-1">
|
||||
{#if model.capabilities.vision}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
{...tooltip.trigger}
|
||||
class="rounded-md border-violet-500 bg-violet-500/50 p-1 text-violet-400"
|
||||
>
|
||||
<EyeIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports vision/image analysis
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if model.capabilities.reasoning}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
{...tooltip.trigger}
|
||||
class="rounded-md border-green-500 bg-green-500/50 p-1 text-green-400"
|
||||
>
|
||||
<BrainIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports reasoning
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if model.capabilities.streaming}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
{...tooltip.trigger}
|
||||
class="rounded-md border-blue-500 bg-blue-500/50 p-1 text-blue-400"
|
||||
>
|
||||
<ZapIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports streaming responses
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if model.capabilities.functionCalling}
|
||||
<Tooltip>
|
||||
{#snippet trigger(tooltip)}
|
||||
<div
|
||||
{...tooltip.trigger}
|
||||
class="rounded-md border-orange-500 bg-orange-500/50 p-1 text-orange-400"
|
||||
>
|
||||
<CpuIcon class="size-3" />
|
||||
</div>
|
||||
{/snippet}
|
||||
Supports tool/function calling
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Model info -->
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{#if pricingInfo}
|
||||
<span>{pricingInfo}</span>
|
||||
{/if}
|
||||
<span>{contextInfo}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { ResultAsync } from 'neverthrow';
|
||||
import { z } from 'zod/v4';
|
||||
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||
import { api } from '$lib/backend/convex/_generated/api';
|
||||
import { parseMessageForRules } from '$lib/utils/rules';
|
||||
import { Provider } from '$lib/types';
|
||||
|
||||
const FREE_MODEL = 'google/gemma-3-27b-it';
|
||||
import { createModelManager } from '$lib/services/model-manager';
|
||||
import type { UserApiKeys } from '$lib/services/model-manager';
|
||||
|
||||
const reqBodySchema = z.object({
|
||||
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 }) => {
|
||||
const bodyResult = await ResultAsync.fromPromise(
|
||||
request.json(),
|
||||
|
|
@ -53,39 +73,60 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||
return error(401, 'You must be logged in to enhance a prompt');
|
||||
}
|
||||
|
||||
const [rulesResult, keyResult] = await Promise.all([
|
||||
ResultAsync.fromPromise(
|
||||
// Get user API keys
|
||||
const userApiKeys = await getUserApiKeys(session.session.token);
|
||||
if (!userApiKeys) {
|
||||
return error(500, 'Failed to get user API keys');
|
||||
}
|
||||
|
||||
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
|
||||
if (!hasAnyKey) {
|
||||
return error(
|
||||
400,
|
||||
'No API keys configured. Please add at least one provider API key in settings to enhance prompts.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get user rules for context
|
||||
const rulesResult = await ResultAsync.fromPromise(
|
||||
client.query(api.user_rules.all, {
|
||||
session_token: session.session.token,
|
||||
}),
|
||||
(e) => `Failed to get rules: ${e}`
|
||||
),
|
||||
ResultAsync.fromPromise(
|
||||
client.query(api.user_keys.get, {
|
||||
provider: Provider.OpenRouter,
|
||||
session_token: session.session.token,
|
||||
}),
|
||||
(e) => `Failed to get API key: ${e}`
|
||||
),
|
||||
]);
|
||||
);
|
||||
|
||||
if (rulesResult.isErr()) {
|
||||
return error(500, 'Failed to get rules');
|
||||
}
|
||||
|
||||
if (keyResult.isErr()) {
|
||||
return error(500, 'Failed to get key');
|
||||
}
|
||||
|
||||
const mentionedRules = parseMessageForRules(
|
||||
args.prompt,
|
||||
rulesResult.value.filter((r) => r.attach === 'manual')
|
||||
);
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY,
|
||||
});
|
||||
// Initialize model manager with user's API keys
|
||||
const modelManager = createModelManager();
|
||||
modelManager.initializeProviders(userApiKeys);
|
||||
|
||||
// Try to find a fast, cheap model for prompt enhancement
|
||||
const availableModels = await modelManager.listAvailableModels();
|
||||
const enhanceModel =
|
||||
availableModels.find(
|
||||
(model) =>
|
||||
model.id.includes('kimi-k2') ||
|
||||
model.id.includes('gemini-2.5-flash-lite') ||
|
||||
model.id.includes('gpt-5-mini') ||
|
||||
model.id.includes('mistral-small')
|
||||
) || availableModels[0];
|
||||
|
||||
if (!enhanceModel) {
|
||||
return error(500, 'No suitable models available for prompt enhancement');
|
||||
}
|
||||
|
||||
const provider = modelManager.getProvider(enhanceModel.provider);
|
||||
if (!provider) {
|
||||
return error(500, `Provider ${enhanceModel.provider} not available`);
|
||||
}
|
||||
|
||||
const 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.
|
||||
|
|
@ -107,23 +148,24 @@ ${args.prompt}
|
|||
`;
|
||||
|
||||
const enhancedResult = await ResultAsync.fromPromise(
|
||||
openai.chat.completions.create({
|
||||
model: FREE_MODEL,
|
||||
provider.generateCompletion({
|
||||
model: enhanceModel.id,
|
||||
messages: [{ role: 'user', content: enhancePrompt }],
|
||||
temperature: 0.5,
|
||||
maxTokens: 1000,
|
||||
}),
|
||||
(e) => `Enhance prompt API call failed: ${e}`
|
||||
);
|
||||
|
||||
if (enhancedResult.isErr()) {
|
||||
return error(500, 'error enhancing the prompt');
|
||||
return error(500, 'Error enhancing the prompt');
|
||||
}
|
||||
|
||||
const enhancedResponse = enhancedResult.value;
|
||||
const enhanced = enhancedResponse.choices[0]?.message?.content;
|
||||
const enhanced = enhancedResponse.content?.trim();
|
||||
|
||||
if (!enhanced) {
|
||||
return error(500, 'error enhancing the prompt');
|
||||
return error(500, 'Error enhancing the prompt');
|
||||
}
|
||||
|
||||
return response({
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
||||
import { api } from '$lib/backend/convex/_generated/api';
|
||||
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
||||
import { Provider } from '$lib/types';
|
||||
import { Provider, type Annotation } from '$lib/types';
|
||||
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { waitUntil } from '@vercel/functions';
|
||||
import { getSessionCookie } from 'better-auth/cookies';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { err, ok, Result, ResultAsync } from 'neverthrow';
|
||||
import OpenAI from 'openai';
|
||||
import { z } from 'zod/v4';
|
||||
import { generationAbortControllers } from './cache.js';
|
||||
import { md } from '$lib/utils/markdown-it.js';
|
||||
import * as array from '$lib/utils/array';
|
||||
import { parseMessageForRules } from '$lib/utils/rules.js';
|
||||
import { createModelManager, type ChatModelManager } from '$lib/services/model-manager.js';
|
||||
import type { UserApiKeys } from '$lib/services/model-manager.js';
|
||||
|
||||
// Set to true to enable debug logging
|
||||
const ENABLE_LOGGING = true;
|
||||
|
|
@ -22,24 +22,26 @@ const reqBodySchema = z
|
|||
.object({
|
||||
message: z.string().optional(),
|
||||
model_id: z.string(),
|
||||
|
||||
session_token: z.string(),
|
||||
conversation_id: z.string().optional(),
|
||||
web_search_enabled: z.boolean().optional(),
|
||||
images: z
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(['image', 'video', 'audio', 'document']),
|
||||
url: z.string(),
|
||||
storage_id: z.string(),
|
||||
fileName: z.string().optional(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.conversation_id === undefined && data.message === undefined) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
|
|
@ -66,34 +68,45 @@ function log(message: string, startTime: number): void {
|
|||
|
||||
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
||||
|
||||
async function getUserApiKeys(sessionToken: string): Promise<Result<UserApiKeys, string>> {
|
||||
const keysResult = await ResultAsync.fromPromise(
|
||||
client.query(api.user_keys.all, {
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
(e) => `Failed to get user API keys: ${e}`
|
||||
);
|
||||
|
||||
if (keysResult.isErr()) {
|
||||
return err(keysResult.error);
|
||||
}
|
||||
|
||||
const keys = keysResult.value;
|
||||
return ok({
|
||||
openai: keys.openai,
|
||||
anthropic: keys.anthropic,
|
||||
google: keys.gemini,
|
||||
mistral: keys.mistral,
|
||||
cohere: keys.cohere,
|
||||
openrouter: keys.openrouter,
|
||||
});
|
||||
}
|
||||
|
||||
async function generateConversationTitle({
|
||||
conversationId,
|
||||
sessionToken,
|
||||
startTime,
|
||||
keyResultPromise,
|
||||
userMessage,
|
||||
modelManager,
|
||||
}: {
|
||||
conversationId: string;
|
||||
sessionToken: string;
|
||||
startTime: number;
|
||||
keyResultPromise: ResultAsync<string | null, string>;
|
||||
userMessage: string;
|
||||
modelManager: ChatModelManager;
|
||||
}) {
|
||||
log('Starting conversation title generation', startTime);
|
||||
|
||||
const keyResult = await keyResultPromise;
|
||||
|
||||
if (keyResult.isErr()) {
|
||||
log(`Title generation: API key error: ${keyResult.error}`, startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const userKey = keyResult.value;
|
||||
const actualKey = userKey || OPENROUTER_FREE_KEY;
|
||||
|
||||
log(`Title generation: Using ${userKey ? 'user' : 'free tier'} API key`, startTime);
|
||||
|
||||
// Only generate title if conversation currently has default title
|
||||
// Check if conversation currently has default title
|
||||
const conversationResult = await ResultAsync.fromPromise(
|
||||
client.query(api.conversations.get, {
|
||||
session_token: sessionToken,
|
||||
|
|
@ -114,12 +127,25 @@ async function generateConversationTitle({
|
|||
return;
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: actualKey,
|
||||
});
|
||||
// Try to find a fast, cheap model for title generation
|
||||
const availableModels = await modelManager.listAvailableModels();
|
||||
const titleModel =
|
||||
availableModels.find((model) => model.id.includes('gemini-2.5-flash-lite')) ||
|
||||
availableModels.find((model) => model.id.includes('kimi-k2')) ||
|
||||
availableModels.find((model) => model.id.includes('gpt-5-mini')) ||
|
||||
availableModels[0];
|
||||
|
||||
if (!titleModel) {
|
||||
log('Title generation: No suitable model available', startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = modelManager.getProvider(titleModel.provider);
|
||||
if (!provider) {
|
||||
log(`Title generation: Provider ${titleModel.provider} not found`, startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a prompt for title generation using only the first user message
|
||||
const titlePrompt = `Based on this message:
|
||||
"""${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.
|
||||
|
||||
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(
|
||||
openai.chat.completions.create({
|
||||
model: 'mistralai/ministral-8b',
|
||||
provider.generateCompletion({
|
||||
model: titleModel.id,
|
||||
messages: [{ role: 'user', content: titlePrompt }],
|
||||
max_tokens: 20,
|
||||
maxTokens: 1024,
|
||||
temperature: 0.5,
|
||||
}),
|
||||
(e) => `Title generation API call failed: ${e}`
|
||||
);
|
||||
|
||||
if (titleResult.isErr()) {
|
||||
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime);
|
||||
log(`Title generation: API call failed: ${titleResult.error}`, startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
const titleResponse = titleResult.value;
|
||||
const rawTitle = titleResponse.choices[0]?.message?.content?.trim();
|
||||
const rawTitle = titleResponse.content?.trim();
|
||||
|
||||
if (!rawTitle) {
|
||||
log('Title generation: No title generated', startTime);
|
||||
|
|
@ -179,20 +204,20 @@ async function generateAIResponse({
|
|||
conversationId,
|
||||
sessionToken,
|
||||
startTime,
|
||||
modelResultPromise,
|
||||
keyResultPromise,
|
||||
modelId,
|
||||
modelManager,
|
||||
rulesResultPromise,
|
||||
userSettingsPromise,
|
||||
abortSignal,
|
||||
reasoningEffort,
|
||||
}: {
|
||||
conversationId: string;
|
||||
sessionToken: string;
|
||||
startTime: number;
|
||||
keyResultPromise: ResultAsync<string | null, string>;
|
||||
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
|
||||
modelId: string;
|
||||
modelManager: ChatModelManager;
|
||||
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
|
||||
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
|
||||
abortSignal?: AbortSignal;
|
||||
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||
}) {
|
||||
log('Starting AI response generation in background', startTime);
|
||||
|
||||
|
|
@ -201,10 +226,34 @@ async function generateAIResponse({
|
|||
return;
|
||||
}
|
||||
|
||||
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] =
|
||||
await Promise.all([
|
||||
modelResultPromise,
|
||||
keyResultPromise,
|
||||
// Get model and provider
|
||||
const model = await modelManager.getModel(modelId);
|
||||
if (!model) {
|
||||
handleGenerationError({
|
||||
error: `Model ${modelId} not found or not available`,
|
||||
conversationId,
|
||||
messageId: undefined,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = modelManager.getProvider(model.provider);
|
||||
if (!provider) {
|
||||
handleGenerationError({
|
||||
error: `Provider ${model.provider} not available`,
|
||||
conversationId,
|
||||
messageId: undefined,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Background: Using model ${modelId} with provider ${model.provider}`, startTime);
|
||||
|
||||
const [messagesQueryResult, rulesResult] = await Promise.all([
|
||||
ResultAsync.fromPromise(
|
||||
client.query(api.messages.getAllFromConversation, {
|
||||
conversation_id: conversationId as Id<'conversations'>,
|
||||
|
|
@ -213,34 +262,8 @@ async function generateAIResponse({
|
|||
(e) => `Failed to get messages: ${e}`
|
||||
),
|
||||
rulesResultPromise,
|
||||
userSettingsPromise,
|
||||
]);
|
||||
|
||||
if (modelResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: modelResult.error,
|
||||
conversationId,
|
||||
messageId: undefined,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const model = modelResult.value;
|
||||
if (!model) {
|
||||
handleGenerationError({
|
||||
error: 'Model not found or not enabled',
|
||||
conversationId,
|
||||
messageId: undefined,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log('Background: Model found and enabled', startTime);
|
||||
|
||||
if (messagesQueryResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: `messages query failed: ${messagesQueryResult.error}`,
|
||||
|
|
@ -259,14 +282,14 @@ async function generateAIResponse({
|
|||
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
|
||||
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
|
||||
const messageCreationResult = await ResultAsync.fromPromise(
|
||||
client.mutation(api.messages.create, {
|
||||
conversation_id: conversationId,
|
||||
model_id: model.model_id,
|
||||
provider: Provider.OpenRouter,
|
||||
model_id: modelId,
|
||||
provider: model.provider as Provider,
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
session_token: sessionToken,
|
||||
|
|
@ -289,84 +312,6 @@ async function generateAIResponse({
|
|||
const mid = messageCreationResult.value;
|
||||
log('Background: Assistant message created', startTime);
|
||||
|
||||
if (keyResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: `API key query failed: ${keyResult.error}`,
|
||||
conversationId,
|
||||
messageId: mid,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userSettingsResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: `User settings query failed: ${userSettingsResult.error}`,
|
||||
conversationId,
|
||||
messageId: mid,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userKey = keyResult.value;
|
||||
const userSettings = userSettingsResult.value;
|
||||
let actualKey: string;
|
||||
|
||||
if (userKey) {
|
||||
// User has their own API key
|
||||
actualKey = userKey;
|
||||
log('Background: Using user API key', startTime);
|
||||
} else {
|
||||
// User doesn't have API key, check if using a free model
|
||||
const isFreeModel = model.model_id.endsWith(':free');
|
||||
|
||||
if (!isFreeModel) {
|
||||
// For non-free models, check the 10 message limit
|
||||
const freeMessagesUsed = userSettings?.free_messages_used || 0;
|
||||
|
||||
if (freeMessagesUsed >= 10) {
|
||||
handleGenerationError({
|
||||
error:
|
||||
'Free message limit reached (10/10). Please add your own OpenRouter API key to continue chatting, or use a free model ending in ":free".',
|
||||
conversationId,
|
||||
messageId: mid,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment free message count before generating (only for non-free models)
|
||||
const incrementResult = await ResultAsync.fromPromise(
|
||||
client.mutation(api.user_settings.incrementFreeMessageCount, {
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
(e) => `Failed to increment free message count: ${e}`
|
||||
);
|
||||
|
||||
if (incrementResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: `Failed to track free message usage: ${incrementResult.error}`,
|
||||
conversationId,
|
||||
messageId: mid,
|
||||
sessionToken,
|
||||
startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Background: Using free tier (${freeMessagesUsed + 1}/10 messages)`, startTime);
|
||||
} else {
|
||||
log(`Background: Using free model (${model.model_id}) - no message count`, startTime);
|
||||
}
|
||||
|
||||
// Use environment OpenRouter key
|
||||
actualKey = OPENROUTER_FREE_KEY;
|
||||
}
|
||||
|
||||
if (rulesResult.isErr()) {
|
||||
handleGenerationError({
|
||||
error: `rules query failed: ${rulesResult.error}`,
|
||||
|
|
@ -402,7 +347,7 @@ async function generateAIResponse({
|
|||
attachedRules.push(...parsedRules);
|
||||
}
|
||||
|
||||
// remove duplicates
|
||||
// Remove duplicates
|
||||
attachedRules = array.fromMap(
|
||||
array.toMap(attachedRules, (r) => [r._id, r]),
|
||||
(_k, v) => v
|
||||
|
|
@ -410,24 +355,20 @@ async function generateAIResponse({
|
|||
|
||||
log(`Background: ${attachedRules.length} rules attached`, startTime);
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: actualKey,
|
||||
});
|
||||
|
||||
const formattedMessages = messages.map((m) => {
|
||||
if (m.images && m.images.length > 0 && m.role === 'user') {
|
||||
return {
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: m.content },
|
||||
...m.images.map((img) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: img.url },
|
||||
})),
|
||||
],
|
||||
};
|
||||
if (m.attachments && m.attachments.length > 0 && m.role === 'user') {
|
||||
const contentParts = [
|
||||
{ type: 'text', text: m.content },
|
||||
...m.attachments.map(attachment => ({
|
||||
type: attachment.type,
|
||||
[`${attachment.type}Url`]: attachment.url,
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
];
|
||||
|
||||
return { role: 'user' as const, content: contentParts };
|
||||
}
|
||||
|
||||
return {
|
||||
role: m.role as 'user' | 'assistant' | 'system',
|
||||
content: m.content,
|
||||
|
|
@ -459,24 +400,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
return;
|
||||
}
|
||||
|
||||
const streamResult = await ResultAsync.fromPromise(
|
||||
openai.chat.completions.create(
|
||||
{
|
||||
model: modelId,
|
||||
// Generate streaming completion
|
||||
let stream: AsyncIterable<any>;
|
||||
try {
|
||||
stream = provider.streamCompletion({
|
||||
model: finalModelId,
|
||||
messages: messagesToSend,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
signal: abortSignal,
|
||||
}
|
||||
),
|
||||
(e) => `OpenAI API call failed: ${e}`
|
||||
);
|
||||
|
||||
if (streamResult.isErr()) {
|
||||
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
|
||||
});
|
||||
log('Background: Stream created successfully', startTime);
|
||||
} catch (error) {
|
||||
handleGenerationError({
|
||||
error: `Failed to create stream: ${streamResult.error}`,
|
||||
error: `Failed to create stream: API call failed: ${error}`,
|
||||
conversationId,
|
||||
messageId: mid,
|
||||
sessionToken,
|
||||
|
|
@ -485,14 +421,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
return;
|
||||
}
|
||||
|
||||
const stream = streamResult.value;
|
||||
log('Background: OpenAI stream created successfully', startTime);
|
||||
|
||||
let content = '';
|
||||
let reasoning = '';
|
||||
let chunkCount = 0;
|
||||
let generationId: string | null = null;
|
||||
const annotations: Annotation[] = [];
|
||||
|
||||
try {
|
||||
// Handle streaming response
|
||||
for await (const chunk of stream) {
|
||||
if (abortSignal?.aborted) {
|
||||
log('AI response generation aborted during streaming', startTime);
|
||||
|
|
@ -500,16 +436,30 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
}
|
||||
|
||||
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 || [];
|
||||
|
||||
reasoning += chunkReasoning;
|
||||
content += chunkContent;
|
||||
annotations.push(...chunkAnnotations);
|
||||
|
||||
if (!chunkContent && !chunkReasoning) continue;
|
||||
|
||||
generationId = chunk.id || generationId;
|
||||
|
||||
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}`
|
||||
);
|
||||
|
|
@ -521,56 +471,27 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
|
||||
startTime
|
||||
);
|
||||
|
||||
if (!generationId) {
|
||||
log('Background: No generation id found', startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final message update with completion stats
|
||||
const contentHtmlResultPromise = ResultAsync.fromPromise(
|
||||
md.renderAsync(content),
|
||||
(e) => `Failed to render HTML: ${e}`
|
||||
);
|
||||
|
||||
const generationStatsResult = await retryResult(
|
||||
() => getGenerationStats(generationId!, actualKey),
|
||||
{
|
||||
delay: 500,
|
||||
retries: 2,
|
||||
startTime,
|
||||
fnName: 'getGenerationStats',
|
||||
}
|
||||
);
|
||||
|
||||
if (generationStatsResult.isErr()) {
|
||||
log(`Background: Failed to get generation stats: ${generationStatsResult.error}`, startTime);
|
||||
}
|
||||
|
||||
// just default so we don't blow up
|
||||
const generationStats = generationStatsResult.unwrapOr({
|
||||
tokens_completion: undefined,
|
||||
total_cost: undefined,
|
||||
});
|
||||
|
||||
log('Background: Got generation stats', startTime);
|
||||
|
||||
const contentHtmlResult = await contentHtmlResultPromise;
|
||||
|
||||
if (contentHtmlResult.isErr()) {
|
||||
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
|
||||
}
|
||||
|
||||
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
|
||||
const [updateMessageResult, updateGeneratingResult] = await Promise.all([
|
||||
ResultAsync.fromPromise(
|
||||
client.mutation(api.messages.updateMessage, {
|
||||
message_id: mid,
|
||||
token_count: generationStats.tokens_completion,
|
||||
cost_usd: generationStats.total_cost,
|
||||
token_count: undefined, // Will be calculated by provider if available
|
||||
cost_usd: undefined, // Will be calculated by provider if available
|
||||
generation_id: generationId,
|
||||
session_token: sessionToken,
|
||||
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}`
|
||||
),
|
||||
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()) {
|
||||
|
|
@ -608,13 +521,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
}
|
||||
|
||||
log('Background: Message updated', startTime);
|
||||
|
||||
if (updateCostUsdResult.isErr()) {
|
||||
log(`Background cost usd update failed: ${updateCostUsdResult.error}`, startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
log('Background: Cost usd updated', startTime);
|
||||
} catch (error) {
|
||||
handleGenerationError({
|
||||
error: `Stream processing error: ${error}`,
|
||||
|
|
@ -656,7 +562,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
log('Schema validation passed', startTime);
|
||||
|
||||
const cookie = getSessionCookie(request.headers);
|
||||
|
||||
const sessionToken = cookie?.split('.')[0] ?? null;
|
||||
|
||||
if (!sessionToken) {
|
||||
|
|
@ -664,29 +569,37 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
return error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const modelResultPromise = ResultAsync.fromPromise(
|
||||
client.query(api.user_enabled_models.get, {
|
||||
provider: Provider.OpenRouter,
|
||||
model_id: args.model_id,
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
(e) => `Failed to get model: ${e}`
|
||||
);
|
||||
// Get user API keys
|
||||
const userApiKeysResult = await getUserApiKeys(sessionToken);
|
||||
if (userApiKeysResult.isErr()) {
|
||||
log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
|
||||
return error(500, 'Failed to get user API keys');
|
||||
}
|
||||
|
||||
const keyResultPromise = ResultAsync.fromPromise(
|
||||
client.query(api.user_keys.get, {
|
||||
provider: Provider.OpenRouter,
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
(e) => `Failed to get API key: ${e}`
|
||||
);
|
||||
const userApiKeys = userApiKeysResult.value;
|
||||
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
|
||||
|
||||
const userSettingsPromise = ResultAsync.fromPromise(
|
||||
client.query(api.user_settings.get, {
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
(e) => `Failed to get user settings: ${e}`
|
||||
if (!hasAnyKey) {
|
||||
log('User has no API keys configured', startTime);
|
||||
return error(
|
||||
400,
|
||||
'No API keys configured. Please add at least one provider API key in settings.'
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
client.query(api.user_rules.all, {
|
||||
|
|
@ -699,7 +612,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
|
||||
let conversationId = args.conversation_id;
|
||||
if (!conversationId) {
|
||||
// technically zod should catch this but just in case
|
||||
// Create new conversation
|
||||
if (args.message === undefined) {
|
||||
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_html: '',
|
||||
role: 'user',
|
||||
images: args.images,
|
||||
attachments: args.attachments,
|
||||
web_search_enabled: args.web_search_enabled,
|
||||
session_token: sessionToken,
|
||||
}),
|
||||
|
|
@ -730,8 +643,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
conversationId,
|
||||
sessionToken,
|
||||
startTime,
|
||||
keyResultPromise,
|
||||
userMessage: args.message,
|
||||
modelManager,
|
||||
}).catch((error) => {
|
||||
log(`Background title generation error: ${error}`, startTime);
|
||||
})
|
||||
|
|
@ -746,8 +659,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
content: args.message,
|
||||
session_token: args.session_token,
|
||||
model_id: args.model_id,
|
||||
reasoning_effort: args.reasoning_effort,
|
||||
role: 'user',
|
||||
images: args.images,
|
||||
attachments: args.attachments,
|
||||
web_search_enabled: args.web_search_enabled,
|
||||
}),
|
||||
(e) => `Failed to create user message: ${e}`
|
||||
|
|
@ -781,17 +695,17 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||
const abortController = new AbortController();
|
||||
generationAbortControllers.set(conversationId, abortController);
|
||||
|
||||
// Start AI response generation in background - don't await
|
||||
// Start AI response generation in background
|
||||
waitUntil(
|
||||
generateAIResponse({
|
||||
conversationId,
|
||||
sessionToken,
|
||||
startTime,
|
||||
modelResultPromise,
|
||||
keyResultPromise,
|
||||
modelId: args.model_id,
|
||||
modelManager,
|
||||
rulesResultPromise,
|
||||
userSettingsPromise,
|
||||
abortSignal: abortController.signal,
|
||||
reasoningEffort: args.reasoning_effort,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
async function getGenerationStats(
|
||||
generationId: string,
|
||||
token: string
|
||||
): Promise<Result<Data, string>> {
|
||||
try {
|
||||
const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await generation.json();
|
||||
|
||||
if (!data) {
|
||||
return err('No data returned from OpenRouter');
|
||||
}
|
||||
|
||||
return ok(data);
|
||||
} catch {
|
||||
return err('Failed to get generation stats');
|
||||
}
|
||||
}
|
||||
|
||||
async function retryResult<T, E>(
|
||||
fn: () => Promise<Result<T, E>>,
|
||||
{
|
||||
retries,
|
||||
delay,
|
||||
startTime,
|
||||
fnName,
|
||||
}: { retries: number; delay: number; startTime: number; fnName: string }
|
||||
): Promise<Result<T, E>> {
|
||||
let attempts = 0;
|
||||
let lastResult: Result<T, E> | null = null;
|
||||
|
||||
while (attempts <= retries) {
|
||||
lastResult = await fn();
|
||||
|
||||
if (lastResult.isOk()) return lastResult;
|
||||
|
||||
log(`Retrying ${fnName} ${attempts} failed: ${lastResult.error}`, startTime);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!lastResult) throw new Error('This should never happen');
|
||||
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
async function handleGenerationError({
|
||||
error,
|
||||
conversationId,
|
||||
|
|
@ -899,38 +762,3 @@ async function handleGenerationError({
|
|||
|
||||
log('Error updated', startTime);
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
created_at: string;
|
||||
model: string;
|
||||
app_id: string | null;
|
||||
external_user: string | null;
|
||||
streamed: boolean;
|
||||
cancelled: boolean;
|
||||
latency: number;
|
||||
moderation_latency: number | null;
|
||||
generation_time: number;
|
||||
tokens_prompt: number;
|
||||
tokens_completion: number;
|
||||
native_tokens_prompt: number;
|
||||
native_tokens_completion: number;
|
||||
native_tokens_reasoning: number;
|
||||
native_tokens_cached: number;
|
||||
num_media_prompt: number | null;
|
||||
num_media_completion: number | null;
|
||||
num_search_results: number | null;
|
||||
origin: string;
|
||||
is_byok: boolean;
|
||||
finish_reason: string;
|
||||
native_finish_reason: string;
|
||||
usage: number;
|
||||
id: string;
|
||||
upstream_id: string;
|
||||
total_cost: number;
|
||||
cache_discount: number | null;
|
||||
provider_name: string;
|
||||
}
|
||||
|
|
|
|||
218
src/routes/api/validate-key/+server.ts
Normal file
218
src/routes/api/validate-key/+server.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,8 @@
|
|||
import { settings } from '$lib/state/settings.svelte.js';
|
||||
import { Provider } from '$lib/types';
|
||||
import { compressImage } from '$lib/utils/image-compression';
|
||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
||||
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
|
||||
import { getSupportedAttachmentTypes, getFileType, getAcceptString, type ProcessedAttachment } from '$lib/utils/attachment-manager';
|
||||
import { omit, pick } from '$lib/utils/object.js';
|
||||
import { cn } from '$lib/utils/utils.js';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
|
|
@ -30,6 +31,10 @@
|
|||
import SendIcon from '~icons/lucide/arrow-up';
|
||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||
import ImageIcon from '~icons/lucide/image';
|
||||
import VideoIcon from '~icons/lucide/video';
|
||||
import AudioIcon from '~icons/lucide/music';
|
||||
import FileTextIcon from '~icons/lucide/file-text';
|
||||
import PaperclipIcon from '~icons/lucide/paperclip';
|
||||
import PanelLeftIcon from '~icons/lucide/panel-left';
|
||||
import SearchIcon from '~icons/lucide/search';
|
||||
import Settings2Icon from '~icons/lucide/settings-2';
|
||||
|
|
@ -38,13 +43,16 @@
|
|||
import XIcon from '~icons/lucide/x';
|
||||
import { callCancelGeneration } from '../api/cancel-generation/call.js';
|
||||
import { callGenerateMessage } from '../api/generate-message/call.js';
|
||||
import ModelPicker from './model-picker.svelte';
|
||||
import { ModelPicker } from '$lib/components/model-picker';
|
||||
import SearchModal from './search-modal.svelte';
|
||||
import { shortcut } from '$lib/actions/shortcut.svelte.js';
|
||||
import { mergeAttrs } from 'melt';
|
||||
import { callEnhancePrompt } from '../api/enhance-prompt/call.js';
|
||||
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||
import SparkleIcon from '~icons/lucide/sparkle';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import * as casing from '$lib/utils/casing.js';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
|
|
@ -116,8 +124,8 @@
|
|||
|
||||
loading = true;
|
||||
|
||||
const imagesCopy = [...selectedImages];
|
||||
selectedImages = [];
|
||||
const attachmentsCopy = [...selectedAttachments];
|
||||
selectedAttachments = [];
|
||||
|
||||
try {
|
||||
const res = await callGenerateMessage({
|
||||
|
|
@ -125,8 +133,9 @@
|
|||
session_token: session.current?.session.token,
|
||||
conversation_id: page.params.id ?? undefined,
|
||||
model_id: settings.modelId,
|
||||
images: imagesCopy.length > 0 ? imagesCopy : undefined,
|
||||
attachments: attachmentsCopy.length > 0 ? attachmentsCopy : undefined,
|
||||
web_search_enabled: settings.webSearchEnabled,
|
||||
reasoning_effort: currentModelSupportsReasoning ? settings.reasoningEffort : undefined,
|
||||
});
|
||||
|
||||
if (res.isErr()) {
|
||||
|
|
@ -191,7 +200,7 @@
|
|||
const autosize = new TextareaAutosize();
|
||||
|
||||
const message = new PersistedState('prompt', '');
|
||||
let selectedImages = $state<{ url: string; storage_id: string; fileName?: string }[]>([]);
|
||||
let selectedAttachments = $state<ProcessedAttachment[]>([]);
|
||||
let isUploading = $state(false);
|
||||
let fileInput = $state<HTMLInputElement>();
|
||||
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
|
||||
|
|
@ -207,45 +216,65 @@
|
|||
|
||||
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;
|
||||
const openRouterModels = models.from(Provider.OpenRouter);
|
||||
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
|
||||
return currentModel ? supportsImages(currentModel) : false;
|
||||
const allModels = models.all();
|
||||
const currentModel = allModels.find((m) => m.id === settings.modelId);
|
||||
if (!currentModel) return false;
|
||||
return supportsReasoning(currentModel);
|
||||
});
|
||||
|
||||
const fileUpload = new FileUpload({
|
||||
multiple: true,
|
||||
accept: 'image/*',
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
maxSize: 100 * 1024 * 1024, // 100MB max for any file type
|
||||
});
|
||||
|
||||
// Update file input accept attribute reactively
|
||||
$effect(() => {
|
||||
if (fileInput) {
|
||||
fileInput.accept = getAcceptString(supportedAttachmentTypes);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFileChange(files: File[]) {
|
||||
if (!files.length || !session.current?.session.token) return;
|
||||
if (!files.length || !session.current?.session.token || !currentModel) return;
|
||||
|
||||
isUploading = true;
|
||||
const uploadedFiles: { url: string; storage_id: string; fileName?: string }[] = [];
|
||||
const uploadedFiles: ProcessedAttachment[] = [];
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Skip non-image files
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.warn('Skipping non-image file:', file.name);
|
||||
// Simple file validation
|
||||
const fileType = getFileType(file);
|
||||
if (!fileType || !supportedAttachmentTypes.includes(fileType)) {
|
||||
console.warn(`Unsupported file type: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compress image to max 1MB
|
||||
const compressedFile = await compressImage(file, 1024 * 1024);
|
||||
// Compress images for better performance
|
||||
let fileToUpload = file;
|
||||
if (fileType === 'image') {
|
||||
fileToUpload = await compressImage(file, 1024 * 1024);
|
||||
}
|
||||
|
||||
// Generate upload URL
|
||||
const uploadUrl = await client.mutation(api.storage.generateUploadUrl, {
|
||||
session_token: session.current.session.token,
|
||||
});
|
||||
|
||||
// Upload compressed file
|
||||
// Upload file
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: compressedFile,
|
||||
body: fileToUpload,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
|
@ -261,11 +290,18 @@
|
|||
});
|
||||
|
||||
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) {
|
||||
console.error('Upload failed:', error);
|
||||
} finally {
|
||||
|
|
@ -273,8 +309,31 @@
|
|||
}
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
selectedImages = selectedImages.filter((_, i) => i !== index);
|
||||
function removeAttachment(index: number) {
|
||||
selectedAttachments = selectedAttachments.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function getRequiredCapabilities(attachments: ProcessedAttachment[]): Array<'vision' | 'audio' | 'video' | 'documents'> {
|
||||
const capabilities: Array<'vision' | 'audio' | 'video' | 'documents'> = [];
|
||||
|
||||
attachments.forEach(attachment => {
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
if (!capabilities.includes('vision')) capabilities.push('vision');
|
||||
break;
|
||||
case 'video':
|
||||
if (!capabilities.includes('video')) capabilities.push('video');
|
||||
break;
|
||||
case 'audio':
|
||||
if (!capabilities.includes('audio')) capabilities.push('audio');
|
||||
break;
|
||||
case 'document':
|
||||
if (!capabilities.includes('documents')) capabilities.push('documents');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function openImageModal(imageUrl: string, fileName: string) {
|
||||
|
|
@ -434,7 +493,7 @@
|
|||
<Sidebar.Root
|
||||
bind:open={sidebarOpen}
|
||||
class="fill-device-height overflow-clip"
|
||||
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
|
||||
{...supportedAttachmentTypes.length > 0 ? omit(fileUpload.dropzone, ['onclick']) : {}}
|
||||
>
|
||||
<AppSidebar bind:searchModalOpen />
|
||||
|
||||
|
|
@ -596,26 +655,45 @@
|
|||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-grow flex-col">
|
||||
{#if selectedImages.length > 0}
|
||||
{#if selectedAttachments.length > 0}
|
||||
<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
|
||||
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(image.url, image.fileName || 'image')}
|
||||
onclick={() => openImageModal(attachment.url, attachment.fileName)}
|
||||
class="rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
src={attachment.url}
|
||||
alt="Uploaded"
|
||||
class="size-10 rounded-lg object-cover opacity-100 transition-opacity"
|
||||
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
|
||||
type="button"
|
||||
onclick={() => removeImage(index)}
|
||||
onclick={() => removeAttachment(index)}
|
||||
class="bg-secondary hover:bg-muted absolute -top-1 -right-1 cursor-pointer rounded-full p-1 opacity-0 transition group-hover:opacity-100"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
|
|
@ -695,8 +773,34 @@
|
|||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
{#if currentModelSupportsReasoning}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
|
||||
>
|
||||
<BrainIcon class="!size-3" />
|
||||
<span class="hidden whitespace-nowrap sm:inline">
|
||||
{casing.camelToPascal(settings.reasoningEffort)}
|
||||
</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="start">
|
||||
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'high')}>
|
||||
<BrainIcon class="size-4" />
|
||||
High
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'medium')}>
|
||||
<BrainIcon class="size-4" />
|
||||
Medium
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'low')}>
|
||||
<BrainIcon class="size-4" />
|
||||
Low
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
|
|
@ -708,7 +812,7 @@
|
|||
<SearchIcon class="!size-3" />
|
||||
<span class="hidden whitespace-nowrap sm:inline">Web search</span>
|
||||
</button>
|
||||
{#if currentModelSupportsImages}
|
||||
{#if supportedAttachmentTypes.length > 0}
|
||||
<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"
|
||||
|
|
@ -720,9 +824,15 @@
|
|||
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
></div>
|
||||
{:else}
|
||||
<ImageIcon class="!size-3" />
|
||||
<PaperclipIcon class="!size-3" />
|
||||
{/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>
|
||||
{/if}
|
||||
{#if session.current !== null && message.current.trim() !== ''}
|
||||
|
|
@ -773,12 +883,20 @@
|
|||
</div>
|
||||
</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="text-center">
|
||||
<UploadIcon class="text-primary mx-auto mb-4 h-16 w-16" />
|
||||
<p class="text-xl font-semibold">Add image</p>
|
||||
<p class="mt-2 text-sm opacity-75">Drop an image here to attach it to your message.</p>
|
||||
<p class="text-xl font-semibold">
|
||||
{#if supportedAttachmentTypes.length === 1 && supportedAttachmentTypes[0]}
|
||||
Add {supportedAttachmentTypes[0]}
|
||||
{:else}
|
||||
Add files
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-2 text-sm opacity-75">
|
||||
Drop {supportedAttachmentTypes.length === 1 ? `${supportedAttachmentTypes[0]}s` : 'supported files'} here to attach to your message.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@
|
|||
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
|
||||
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||
provider: Provider.OpenRouter,
|
||||
const userKeysQuery = useCachedQuery(api.user_keys.all, {
|
||||
session_token: session.current?.session.token ?? '',
|
||||
});
|
||||
|
||||
|
|
@ -76,7 +75,7 @@
|
|||
</svelte:head>
|
||||
|
||||
<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 }}>
|
||||
<h2 class="text-left font-serif text-3xl font-semibold">
|
||||
Hey there <span class={{ 'blur-sm': settings.data?.privacy_mode }}
|
||||
|
|
@ -131,7 +130,7 @@
|
|||
{/if}
|
||||
</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 }}>
|
||||
<h2 class="text-left font-serif text-3xl font-semibold">
|
||||
Hey there, <span class={{ 'blur-sm': settings.data?.privacy_mode }}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
import { last } from '$lib/utils/array';
|
||||
import { settings } from '$lib/state/settings.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||
import GlobeIcon from '~icons/lucide/globe';
|
||||
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
||||
|
||||
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
|
||||
conversation_id: page.params.id ?? '',
|
||||
|
|
@ -21,6 +24,8 @@
|
|||
session_token: session.current?.session.token ?? '',
|
||||
}));
|
||||
|
||||
const lastMessage = $derived(messages?.data?.[messages.data?.length - 1] ?? null);
|
||||
|
||||
const lastMessageHasContent = $derived.by(() => {
|
||||
if (!messages.data) return false;
|
||||
const lastMessage = messages.data[messages.data.length - 1];
|
||||
|
|
@ -32,6 +37,15 @@
|
|||
return lastMessage.content.length > 0;
|
||||
});
|
||||
|
||||
const lastMessageHasReasoning = $derived.by(() => {
|
||||
if (!messages.data) return false;
|
||||
const lastMessage = messages.data[messages.data.length - 1];
|
||||
|
||||
if (!lastMessage) return false;
|
||||
|
||||
return lastMessage.reasoning?.length ?? 0 > 0;
|
||||
});
|
||||
|
||||
let changedRoute = $state(false);
|
||||
watch(
|
||||
() => page.params.id,
|
||||
|
|
@ -74,8 +88,23 @@
|
|||
{#each messages.data ?? [] as message (message._id)}
|
||||
<Message {message} />
|
||||
{/each}
|
||||
{#if conversation.data?.generating && !lastMessageHasContent}
|
||||
{#if conversation.data?.generating}
|
||||
{#if lastMessage?.web_search_enabled}
|
||||
{#if lastMessage?.annotations === undefined || lastMessage?.annotations?.length === 0}
|
||||
<div class="flex place-items-center gap-2">
|
||||
<GlobeIcon class="inline size-4 shrink-0" />
|
||||
<ShinyText class="text-muted-foreground text-sm">Searching the web...</ShinyText>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !lastMessageHasReasoning && !lastMessageHasContent}
|
||||
<LoadingDots />
|
||||
{:else}
|
||||
<div class="flex place-items-center gap-2">
|
||||
<div class="flex animate-[spin_0.65s_linear_infinite] place-items-center justify-center">
|
||||
<LoaderCircleIcon class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { CopyButton } from '$lib/components/ui/copy-button';
|
||||
import '../../../markdown.css';
|
||||
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 { on } from 'svelte/events';
|
||||
import { isHtmlElement } from '$lib/utils/is';
|
||||
|
|
@ -19,6 +19,14 @@
|
|||
import { callGenerateMessage } from '../../api/generate-message/call';
|
||||
import * as Icons from '$lib/components/icons';
|
||||
import { settings } from '$lib/state/settings.svelte';
|
||||
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||
import ChevronRightIcon from '~icons/lucide/chevron-right';
|
||||
import { AnnotationSchema, type Annotation } from '$lib/types';
|
||||
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||
import GlobeIcon from '~icons/lucide/globe';
|
||||
import { Avatar } from 'melt/components';
|
||||
import BrainIcon from '~icons/lucide/brain';
|
||||
import * as casing from '$lib/utils/casing';
|
||||
|
||||
const style = tv({
|
||||
base: 'prose rounded-xl p-2 max-w-full',
|
||||
|
|
@ -38,19 +46,6 @@
|
|||
|
||||
let { message }: Props = $props();
|
||||
|
||||
let imageModal = $state<{ open: boolean; imageUrl: string; fileName: string }>({
|
||||
open: false,
|
||||
imageUrl: '',
|
||||
fileName: '',
|
||||
});
|
||||
|
||||
function openImageModal(imageUrl: string, fileName: string) {
|
||||
imageModal = {
|
||||
open: true,
|
||||
imageUrl,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
async function createBranchedConversation() {
|
||||
const res = await ResultAsync.fromPromise(
|
||||
|
|
@ -86,9 +81,27 @@
|
|||
|
||||
await goto(`/chat/${cid}`);
|
||||
}
|
||||
|
||||
const annotations = $derived.by(() => {
|
||||
if (!message.annotations || message.annotations.length === 0) return null;
|
||||
|
||||
const annotations: Annotation[] = [];
|
||||
|
||||
for (const annotation of message.annotations) {
|
||||
const parsed = AnnotationSchema.safeParse(annotation);
|
||||
|
||||
if (!parsed.success) continue;
|
||||
|
||||
annotations.push(parsed.data);
|
||||
}
|
||||
|
||||
return annotations;
|
||||
});
|
||||
|
||||
let showReasoning = $state(false);
|
||||
</script>
|
||||
|
||||
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
|
||||
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && message.reasoning?.length === 0 && !message.error)}
|
||||
<div
|
||||
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
|
||||
{@attach (node) => {
|
||||
|
|
@ -106,21 +119,55 @@
|
|||
});
|
||||
}}
|
||||
>
|
||||
{#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">
|
||||
{#each message.images as image (image.storage_id)}
|
||||
<FilePreview
|
||||
attachment={{
|
||||
type: 'image',
|
||||
url: image.url,
|
||||
fileName: image.fileName || 'image',
|
||||
mimeType: image.mimeType || 'image/jpeg',
|
||||
size: image.size || 0,
|
||||
storage_id: image.storage_id
|
||||
}}
|
||||
isUserMessage={message.role === 'user'}
|
||||
compact={true}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.reasoning}
|
||||
<div class="my-8">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openImageModal(image.url, image.fileName || 'image')}
|
||||
class="rounded-lg"
|
||||
class="text-muted-foreground flex items-center gap-1 pb-2 text-sm"
|
||||
aria-label="Toggle reasoning"
|
||||
onclick={() => (showReasoning = !showReasoning)}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.fileName || 'Uploaded'}
|
||||
class="max-w-xs rounded-lg transition-opacity hover:opacity-80"
|
||||
/>
|
||||
<ChevronRightIcon class={cn('inline size-4', { 'rotate-90': showReasoning })} />
|
||||
{#if message.content.length === 0}
|
||||
<ShinyText>Reasoning...</ShinyText>
|
||||
{:else}
|
||||
<span>Reasoning</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#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 })}>
|
||||
|
|
@ -146,6 +193,58 @@
|
|||
</svelte:boundary>
|
||||
{/if}
|
||||
</div>
|
||||
{#if annotations}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground pl-2 text-xs">
|
||||
{annotations.length}
|
||||
{annotations.length === 1 ? 'Citation' : 'Citations'}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
{#each annotations as annotation}
|
||||
{#if annotation.type === 'url_citation'}
|
||||
{@const url = new URL(annotation.url_citation.url)}
|
||||
<a
|
||||
href={annotation.url_citation.url}
|
||||
target="_blank"
|
||||
class="border-border bg-background bg-noise -m-1 flex place-items-center justify-center rounded-full border p-0.5 transition-transform hover:scale-110"
|
||||
>
|
||||
{@render siteIcon({ url })}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollbar-hide flex place-items-center gap-2 overflow-x-auto p-2">
|
||||
{#each annotations as annotation}
|
||||
{#if annotation.type === 'url_citation'}
|
||||
{@const url = new URL(annotation.url_citation.url)}
|
||||
<div
|
||||
class="border-border hover:border-primary/50 text-muted-foreground group relative flex h-32 min-w-60 flex-col justify-between rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<a
|
||||
href={annotation.url_citation.url}
|
||||
target="_blank"
|
||||
class="group-hover:text-foreground block max-w-full truncate font-medium transition-colors"
|
||||
>
|
||||
<span class="absolute inset-0"></span>
|
||||
{annotation.url_citation.title}
|
||||
</a>
|
||||
<p class="truncate text-sm">
|
||||
{annotation.url_citation.content}
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
{@render siteIcon({ url })}
|
||||
{url.hostname}
|
||||
</span>
|
||||
|
||||
<ExternalLinkIcon class="text-primary absolute top-2 right-2 size-3" />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
|
||||
|
|
@ -188,8 +287,16 @@
|
|||
{#if message.model_id !== undefined}
|
||||
<span class="text-muted-foreground text-xs">{message.model_id}</span>
|
||||
{/if}
|
||||
{#if message.reasoning_effort}
|
||||
<span class="text-muted-foreground text-xs">
|
||||
<BrainIcon class="inline-block size-4 shrink-0 text-green-500" />
|
||||
{casing.camelToPascal(message.reasoning_effort)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if message.web_search_enabled}
|
||||
<span class="text-muted-foreground text-xs"> Web search enabled </span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
<GlobeIcon class="text-primary inline-block size-4 shrink-0" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if message.cost_usd !== undefined}
|
||||
|
|
@ -201,11 +308,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if message.images && message.images.length > 0}
|
||||
<ImageModal
|
||||
bind:open={imageModal.open}
|
||||
imageUrl={imageModal.imageUrl}
|
||||
fileName={imageModal.fileName}
|
||||
/>
|
||||
{/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}
|
||||
|
|
|
|||
|
|
@ -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
1
tmp/kepler-ai-sdk
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 73461f942496d91e098d2d3d61c769571a13cb11
|
||||
Loading…
Add table
Reference in a new issue