Post Hackathon Stuff (#40)
Co-authored-by: Thomas G. Lopes <26071571+TGlide@users.noreply.github.com>
This commit is contained in:
parent
a96ba2152b
commit
7b9595e571
43 changed files with 1798 additions and 400 deletions
|
|
@ -172,4 +172,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||||
<a href="https://github.com/yourusername/thom-chat/issues">💡 Request Feature</a>
|
<a href="https://github.com/yourusername/thom-chat/issues">💡 Request Feature</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/svelte": "^5.2.4",
|
"@testing-library/svelte": "^5.2.4",
|
||||||
"@vercel/functions": "^2.2.0",
|
"@vercel/functions": "^2.2.0",
|
||||||
|
"bits-ui": "^2.8.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"convex": "^1.24.8",
|
"convex": "^1.24.8",
|
||||||
|
|
@ -44,9 +45,10 @@
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"isomorphic-dompurify": "^2.25.0",
|
"isomorphic-dompurify": "^2.25.0",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
|
"melt": "^0.38.0",
|
||||||
"mode-watcher": "^1.0.8",
|
"mode-watcher": "^1.0.8",
|
||||||
"neverthrow": "^8.2.0",
|
"neverthrow": "^8.2.0",
|
||||||
|
"openai": "^5.5.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
|
@ -58,6 +60,7 @@
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-variants": "^1.0.0",
|
"tailwind-variants": "^1.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"unplugin-icons": "^22.1.0",
|
"unplugin-icons": "^22.1.0",
|
||||||
|
|
@ -84,7 +87,6 @@
|
||||||
"convex-helpers": "^0.1.94",
|
"convex-helpers": "^0.1.94",
|
||||||
"hastscript": "^9.0.1",
|
"hastscript": "^9.0.1",
|
||||||
"markdown-it-async": "^2.2.0",
|
"markdown-it-async": "^2.2.0",
|
||||||
"openai": "^5.3.0",
|
|
||||||
"zod": "^3.25.64"
|
"zod": "^3.25.64"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
pnpm-lock.yaml
generated
100
pnpm-lock.yaml
generated
|
|
@ -47,9 +47,6 @@ importers:
|
||||||
markdown-it-async:
|
markdown-it-async:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
openai:
|
|
||||||
specifier: ^5.3.0
|
|
||||||
version: 5.3.0(ws@8.18.2)(zod@3.25.64)
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.25.64
|
specifier: ^3.25.64
|
||||||
version: 3.25.64
|
version: 3.25.64
|
||||||
|
|
@ -99,6 +96,9 @@ importers:
|
||||||
'@vercel/functions':
|
'@vercel/functions':
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
|
bits-ui:
|
||||||
|
specifier: ^2.8.5
|
||||||
|
version: 2.8.5(@internationalized/date@3.8.2)(svelte@5.34.1)
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
|
@ -133,14 +133,17 @@ importers:
|
||||||
specifier: ^26.0.0
|
specifier: ^26.0.0
|
||||||
version: 26.1.0
|
version: 26.1.0
|
||||||
melt:
|
melt:
|
||||||
specifier: https://pkg.vc/-/@melt-ui/melt@42e572f
|
specifier: ^0.38.0
|
||||||
version: https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1)
|
version: 0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1)
|
||||||
mode-watcher:
|
mode-watcher:
|
||||||
specifier: ^1.0.8
|
specifier: ^1.0.8
|
||||||
version: 1.0.8(svelte@5.34.1)
|
version: 1.0.8(svelte@5.34.1)
|
||||||
neverthrow:
|
neverthrow:
|
||||||
specifier: ^8.2.0
|
specifier: ^8.2.0
|
||||||
version: 8.2.0
|
version: 8.2.0
|
||||||
|
openai:
|
||||||
|
specifier: ^5.5.1
|
||||||
|
version: 5.5.1(ws@8.18.2)(zod@3.25.64)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.5.3
|
version: 3.5.3
|
||||||
|
|
@ -174,6 +177,9 @@ importers:
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.1.10
|
version: 4.1.10
|
||||||
|
tw-animate-css:
|
||||||
|
specifier: ^1.3.4
|
||||||
|
version: 1.3.4
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
@ -677,6 +683,9 @@ packages:
|
||||||
'@iconify/utils@2.3.0':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
|
'@internationalized/date@3.8.2':
|
||||||
|
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
@ -921,6 +930,9 @@ packages:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
vite: ^6.0.0
|
vite: ^6.0.0
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.17':
|
||||||
|
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.10':
|
'@tailwindcss/node@4.1.10':
|
||||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||||
|
|
||||||
|
|
@ -1241,6 +1253,13 @@ packages:
|
||||||
better-call@1.0.9:
|
better-call@1.0.9:
|
||||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||||
|
|
||||||
|
bits-ui@2.8.5:
|
||||||
|
resolution: {integrity: sha512-GVVDcmc+mziNNWdzlBviN3HjFAIdEFddQFvTA5cjronMan8PnIhpNhc2+DKL5CYdTbrz6kuyt2YvuvnoWYmovw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
'@internationalized/date': ^3.8.1
|
||||||
|
svelte: ^5.33.0
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
|
|
||||||
|
|
@ -1602,6 +1621,9 @@ packages:
|
||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
|
focus-trap@7.6.5:
|
||||||
|
resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -1919,9 +1941,8 @@ packages:
|
||||||
mdurl@2.0.0:
|
mdurl@2.0.0:
|
||||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
melt@https://pkg.vc/-/@melt-ui/melt@42e572f:
|
melt@0.38.0:
|
||||||
resolution: {tarball: https://pkg.vc/-/@melt-ui/melt@42e572f}
|
resolution: {integrity: sha512-fVUZdZSYUeYw4uwZkY+KoxmbVVMY14qEA21wjIR0ELAMlWc/eSeG0JcZn0wSB7dB1JdWo7KsVrZssieiQWSp5A==}
|
||||||
version: 0.35.0
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@floating-ui/dom': ^1.6.0
|
'@floating-ui/dom': ^1.6.0
|
||||||
svelte: ^5.30.1
|
svelte: ^5.30.1
|
||||||
|
|
@ -1997,11 +2018,6 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
nanoid@5.1.5:
|
|
||||||
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
|
||||||
engines: {node: ^18 || >=20}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
nanostores@0.11.4:
|
nanostores@0.11.4:
|
||||||
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
|
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -2022,8 +2038,8 @@ packages:
|
||||||
oniguruma-to-es@4.3.3:
|
oniguruma-to-es@4.3.3:
|
||||||
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
|
resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==}
|
||||||
|
|
||||||
openai@5.3.0:
|
openai@5.5.1:
|
||||||
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
|
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
ws: ^8.18.0
|
ws: ^8.18.0
|
||||||
|
|
@ -2420,6 +2436,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
|
svelte-toolbelt@0.9.2:
|
||||||
|
resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==}
|
||||||
|
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.30.2
|
||||||
|
|
||||||
svelte@5.34.1:
|
svelte@5.34.1:
|
||||||
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -2427,6 +2449,9 @@ packages:
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
|
tabbable@6.2.0:
|
||||||
|
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||||
|
|
||||||
tailwind-merge@3.0.2:
|
tailwind-merge@3.0.2:
|
||||||
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||||
|
|
||||||
|
|
@ -2514,6 +2539,9 @@ packages:
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
tw-animate-css@1.3.4:
|
||||||
|
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -3102,6 +3130,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@internationalized/date@3.8.2':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.17
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
@ -3347,6 +3379,10 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.17':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.10':
|
'@tailwindcss/node@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|
@ -3695,6 +3731,17 @@ snapshots:
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
uncrypto: 0.1.3
|
uncrypto: 0.1.3
|
||||||
|
|
||||||
|
bits-ui@2.8.5(@internationalized/date@3.8.2)(svelte@5.34.1):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.1
|
||||||
|
'@floating-ui/dom': 1.7.1
|
||||||
|
'@internationalized/date': 3.8.2
|
||||||
|
esm-env: 1.2.2
|
||||||
|
runed: 0.28.0(svelte@5.34.1)
|
||||||
|
svelte: 5.34.1
|
||||||
|
svelte-toolbelt: 0.9.2(svelte@5.34.1)
|
||||||
|
tabbable: 6.2.0
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
@ -4072,6 +4119,10 @@ snapshots:
|
||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
focus-trap@7.6.5:
|
||||||
|
dependencies:
|
||||||
|
tabbable: 6.2.0
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -4389,12 +4440,12 @@ snapshots:
|
||||||
|
|
||||||
mdurl@2.0.0: {}
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
melt@https://pkg.vc/-/@melt-ui/melt@42e572f(@floating-ui/dom@1.7.1)(svelte@5.34.1):
|
melt@0.38.0(@floating-ui/dom@1.7.1)(svelte@5.34.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.1
|
'@floating-ui/dom': 1.7.1
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
focus-trap: 7.6.5
|
||||||
jest-axe: 9.0.0
|
jest-axe: 9.0.0
|
||||||
nanoid: 5.1.5
|
|
||||||
runed: 0.23.4(svelte@5.34.1)
|
runed: 0.23.4(svelte@5.34.1)
|
||||||
svelte: 5.34.1
|
svelte: 5.34.1
|
||||||
|
|
||||||
|
|
@ -4461,8 +4512,6 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
nanoid@5.1.5: {}
|
|
||||||
|
|
||||||
nanostores@0.11.4: {}
|
nanostores@0.11.4: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
@ -4481,7 +4530,7 @@ snapshots:
|
||||||
regex: 6.0.1
|
regex: 6.0.1
|
||||||
regex-recursion: 6.0.2
|
regex-recursion: 6.0.2
|
||||||
|
|
||||||
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
|
openai@5.5.1(ws@8.18.2)(zod@3.25.64):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.18.2
|
ws: 8.18.2
|
||||||
zod: 3.25.64
|
zod: 3.25.64
|
||||||
|
|
@ -4821,6 +4870,13 @@ snapshots:
|
||||||
style-to-object: 1.0.9
|
style-to-object: 1.0.9
|
||||||
svelte: 5.34.1
|
svelte: 5.34.1
|
||||||
|
|
||||||
|
svelte-toolbelt@0.9.2(svelte@5.34.1):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
runed: 0.28.0(svelte@5.34.1)
|
||||||
|
style-to-object: 1.0.9
|
||||||
|
svelte: 5.34.1
|
||||||
|
|
||||||
svelte@5.34.1:
|
svelte@5.34.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|
@ -4840,6 +4896,8 @@ snapshots:
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
|
tabbable@6.2.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.0.2: {}
|
tailwind-merge@3.0.2: {}
|
||||||
|
|
||||||
tailwind-merge@3.3.1: {}
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
@ -4909,6 +4967,8 @@ snapshots:
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tw-animate-css@1.3.4: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
@import '@fontsource-variable/nunito-sans';
|
@import '@fontsource-variable/nunito-sans';
|
||||||
@import '@fontsource/instrument-serif';
|
@import '@fontsource/instrument-serif';
|
||||||
|
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { v } from 'convex/values';
|
||||||
import { api } from './_generated/api';
|
import { api } from './_generated/api';
|
||||||
import { type Id } from './_generated/dataModel';
|
import { type Id } from './_generated/dataModel';
|
||||||
import { query } from './_generated/server';
|
import { query } from './_generated/server';
|
||||||
import { messageRoleValidator, providerValidator } from './schema';
|
import { messageRoleValidator, providerValidator, reasoningEffortValidator } from './schema';
|
||||||
import { mutation } from './functions';
|
import { mutation } from './functions';
|
||||||
|
|
||||||
export const getAllFromConversation = query({
|
export const getAllFromConversation = query({
|
||||||
|
|
@ -47,6 +47,7 @@ export const create = mutation({
|
||||||
provider: v.optional(providerValidator),
|
provider: v.optional(providerValidator),
|
||||||
token_count: v.optional(v.number()),
|
token_count: v.optional(v.number()),
|
||||||
web_search_enabled: v.optional(v.boolean()),
|
web_search_enabled: v.optional(v.boolean()),
|
||||||
|
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||||
// Optional image attachments
|
// Optional image attachments
|
||||||
images: v.optional(
|
images: v.optional(
|
||||||
v.array(
|
v.array(
|
||||||
|
|
@ -94,6 +95,7 @@ export const create = mutation({
|
||||||
provider: args.provider,
|
provider: args.provider,
|
||||||
token_count: args.token_count,
|
token_count: args.token_count,
|
||||||
web_search_enabled: args.web_search_enabled,
|
web_search_enabled: args.web_search_enabled,
|
||||||
|
reasoning_effort: args.reasoning_effort,
|
||||||
// Optional image attachments
|
// Optional image attachments
|
||||||
images: args.images,
|
images: args.images,
|
||||||
}),
|
}),
|
||||||
|
|
@ -112,7 +114,11 @@ export const updateContent = mutation({
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
message_id: v.string(),
|
message_id: v.string(),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
|
reasoning: v.optional(v.string()),
|
||||||
content_html: v.optional(v.string()),
|
content_html: v.optional(v.string()),
|
||||||
|
generation_id: v.optional(v.string()),
|
||||||
|
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||||
|
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
|
||||||
|
|
@ -131,7 +137,11 @@ export const updateContent = mutation({
|
||||||
|
|
||||||
await ctx.db.patch(message._id, {
|
await ctx.db.patch(message._id, {
|
||||||
content: args.content,
|
content: args.content,
|
||||||
|
reasoning: args.reasoning,
|
||||||
content_html: args.content_html,
|
content_html: args.content_html,
|
||||||
|
generation_id: args.generation_id,
|
||||||
|
annotations: args.annotations,
|
||||||
|
reasoning_effort: args.reasoning_effort,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ export const messageRoleValidator = v.union(
|
||||||
v.literal('assistant'),
|
v.literal('assistant'),
|
||||||
v.literal('system')
|
v.literal('system')
|
||||||
);
|
);
|
||||||
|
export const reasoningEffortValidator = v.union(
|
||||||
|
v.literal('low'),
|
||||||
|
v.literal('medium'),
|
||||||
|
v.literal('high')
|
||||||
|
);
|
||||||
|
|
||||||
export type MessageRole = Infer<typeof messageRoleValidator>;
|
export type MessageRole = Infer<typeof messageRoleValidator>;
|
||||||
|
|
||||||
|
|
@ -31,7 +36,8 @@ export default defineSchema({
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
/** Different providers may use different ids for the same model */
|
/** Different providers may use different ids for the same model */
|
||||||
model_id: v.string(),
|
model_id: v.string(),
|
||||||
pinned: v.union(v.number(), v.null()),
|
// null is just here for compat we treat null as true
|
||||||
|
pinned: v.optional(v.union(v.boolean(), v.null())),
|
||||||
})
|
})
|
||||||
.index('by_user', ['user_id'])
|
.index('by_user', ['user_id'])
|
||||||
.index('by_model_provider', ['model_id', 'provider'])
|
.index('by_model_provider', ['model_id', 'provider'])
|
||||||
|
|
@ -61,6 +67,7 @@ export default defineSchema({
|
||||||
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
content_html: v.optional(v.string()),
|
content_html: v.optional(v.string()),
|
||||||
|
reasoning: v.optional(v.string()),
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
// Optional, coming from SK API route
|
// Optional, coming from SK API route
|
||||||
model_id: v.optional(v.string()),
|
model_id: v.optional(v.string()),
|
||||||
|
|
@ -79,5 +86,7 @@ export default defineSchema({
|
||||||
cost_usd: v.optional(v.number()),
|
cost_usd: v.optional(v.number()),
|
||||||
generation_id: v.optional(v.string()),
|
generation_id: v.optional(v.string()),
|
||||||
web_search_enabled: v.optional(v.boolean()),
|
web_search_enabled: v.optional(v.boolean()),
|
||||||
|
reasoning_effort: v.optional(reasoningEffortValidator),
|
||||||
|
annotations: v.optional(v.array(v.record(v.string(), v.any()))),
|
||||||
}).index('by_conversation', ['conversation_id']),
|
}).index('by_conversation', ['conversation_id']),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,38 @@ export const set = mutation({
|
||||||
await ctx.db.insert('user_enabled_models', {
|
await ctx.db.insert('user_enabled_models', {
|
||||||
...object.pick(args, ['provider', 'model_id']),
|
...object.pick(args, ['provider', 'model_id']),
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
pinned: null,
|
pinned: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const toggle_pinned = mutation({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
enabled_model_id: v.id('user_enabled_models'),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
|
const model = await ctx.db.get(args.enabled_model_id);
|
||||||
|
|
||||||
|
if (!model) throw new Error('Model not found');
|
||||||
|
|
||||||
|
await ctx.db.patch(args.enabled_model_id, {
|
||||||
|
pinned: !isPinned(model),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isPinned(model: Doc<'user_enabled_models'>) {
|
||||||
|
return model.pinned === null || model.pinned;
|
||||||
|
}
|
||||||
|
|
||||||
export const enable_initial = mutation({
|
export const enable_initial = mutation({
|
||||||
args: {
|
args: {
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
|
|
@ -150,7 +176,7 @@ export const enable_initial = mutation({
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
model_id: model,
|
model_id: model,
|
||||||
pinned: null,
|
pinned: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -99,14 +99,26 @@ export const set = mutation({
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
defaultModels.map((model) =>
|
defaultModels.map(async (model) => {
|
||||||
ctx.db.insert('user_enabled_models', {
|
const existing = await ctx.db
|
||||||
|
.query('user_enabled_models')
|
||||||
|
.withIndex('by_model_provider_user', (q) =>
|
||||||
|
q
|
||||||
|
.eq('model_id', model)
|
||||||
|
.eq('provider', Provider.OpenRouter)
|
||||||
|
.eq('user_id', session.userId)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
await ctx.db.insert('user_enabled_models', {
|
||||||
user_id: session.userId,
|
user_id: session.userId,
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
model_id: model,
|
model_id: model,
|
||||||
pinned: null,
|
pinned: true,
|
||||||
})
|
});
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<p
|
<p
|
||||||
style:--shimmer-width="{shimmerWidth}px"
|
style:--shimmer-width="{shimmerWidth}px"
|
||||||
class={cn(
|
class={cn(
|
||||||
'mx-auto max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
|
'max-w-md text-neutral-600/50 dark:text-neutral-400/50 ',
|
||||||
|
|
||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
'animate-shimmer [background-size:var(--shimmer-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
'animate-shimmer [background-size:var(--shimmer-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]',
|
||||||
|
|
|
||||||
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 };
|
||||||
545
src/lib/components/model-picker/model-picker.svelte
Normal file
545
src/lib/components/model-picker/model-picker.svelte
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
<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 } from '$lib/types';
|
||||||
|
import { fuzzysearch } from '$lib/utils/fuzzy-search';
|
||||||
|
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
|
||||||
|
import { capitalize } from '$lib/utils/strings';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { type Component } from 'svelte';
|
||||||
|
import LogosClaudeIcon from '~icons/logos/claude-icon';
|
||||||
|
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
|
||||||
|
import BrainIcon from '~icons/lucide/brain';
|
||||||
|
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||||
|
import CpuIcon from '~icons/lucide/cpu';
|
||||||
|
import EyeIcon from '~icons/lucide/eye';
|
||||||
|
import SearchIcon from '~icons/lucide/search';
|
||||||
|
import ZapIcon from '~icons/lucide/zap';
|
||||||
|
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
|
||||||
|
import GoogleIcon from '~icons/simple-icons/google';
|
||||||
|
import MetaIcon from '~icons/simple-icons/meta';
|
||||||
|
import MicrosoftIcon from '~icons/simple-icons/microsoft';
|
||||||
|
import OpenaiIcon from '~icons/simple-icons/openai';
|
||||||
|
import XIcon from '~icons/simple-icons/x';
|
||||||
|
import { Command } from 'bits-ui';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { shortcut } from '$lib/actions/shortcut.svelte';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import ChevronLeftIcon from '~icons/lucide/chevron-left';
|
||||||
|
import { Kbd } from '../ui/kbd';
|
||||||
|
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
import type { Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
|
import { ResultAsync } from 'neverthrow';
|
||||||
|
import PinIcon from '~icons/lucide/pin';
|
||||||
|
import PinOffIcon from '~icons/lucide/pin-off';
|
||||||
|
import { isPinned } from '$lib/backend/convex/user_enabled_models';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
class?: string;
|
||||||
|
/* When images are attached, we should not select models that don't support images */
|
||||||
|
onlyImageModels?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { class: className, onlyImageModels }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||||
|
|
||||||
|
modelsState.init();
|
||||||
|
|
||||||
|
// Company icon mapping
|
||||||
|
const companyIcons: Record<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: '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);
|
||||||
|
let view = $state<'favorites' | 'enabled'>('favorites');
|
||||||
|
let activeModel = $state('');
|
||||||
|
|
||||||
|
// Model name formatting utility
|
||||||
|
const termReplacements = [
|
||||||
|
{ from: 'gpt', to: 'GPT' },
|
||||||
|
{ from: 'claude', to: 'Claude' },
|
||||||
|
{ from: 'deepseek', to: 'DeepSeek' },
|
||||||
|
{ from: 'o3', to: 'o3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatModelName(modelId: string) {
|
||||||
|
const cleanId = modelId.replace(/^[^/]+\//, '');
|
||||||
|
const parts = cleanId.split(/[-_,:]/);
|
||||||
|
|
||||||
|
const formattedParts = parts.map((part) => {
|
||||||
|
let formatted = capitalize(part);
|
||||||
|
termReplacements.forEach(({ from, to }) => {
|
||||||
|
formatted = formatted.replace(new RegExp(`\\b${from}\\b`, 'gi'), to);
|
||||||
|
});
|
||||||
|
return formatted;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
full: formattedParts.join(' '),
|
||||||
|
primary: formattedParts[0] || '',
|
||||||
|
secondary: formattedParts.slice(1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSelected(modelId: string) {
|
||||||
|
settings.modelId = modelId;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleView() {
|
||||||
|
view = view === 'favorites' ? 'enabled' : 'favorites';
|
||||||
|
}
|
||||||
|
|
||||||
|
let pinning = $state(false);
|
||||||
|
|
||||||
|
async function togglePin(modelId: Id<'user_enabled_models'>) {
|
||||||
|
pinning = true;
|
||||||
|
|
||||||
|
await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.user_enabled_models.toggle_pinned, {
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
enabled_model_id: modelId,
|
||||||
|
}),
|
||||||
|
(e) => e
|
||||||
|
);
|
||||||
|
|
||||||
|
pinning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = new IsMobile();
|
||||||
|
|
||||||
|
const activeModelInfo = $derived.by(() => {
|
||||||
|
if (activeModel === '') return null;
|
||||||
|
|
||||||
|
const model = enabledArr.find((m) => m.model_id === activeModel);
|
||||||
|
|
||||||
|
if (!model) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
formatted: formatModelName(activeModel),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedModels = $derived(enabledArr.filter((m) => isPinned(m)));
|
||||||
|
</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.model_id)}
|
||||||
|
{@const IconComponent = getModelIcon(currentModel.model_id)}
|
||||||
|
<IconComponent class="size-3" />
|
||||||
|
{/if}
|
||||||
|
<span class="truncate">
|
||||||
|
{currentModel ? formatModelName(currentModel.model_id).full : 'Select model'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon class="size-4 opacity-50" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Content
|
||||||
|
portalProps={{
|
||||||
|
disabled: true
|
||||||
|
}}
|
||||||
|
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 formatted = formatModelName(model.model_id)}
|
||||||
|
{@const openRouterModel = modelsState
|
||||||
|
.from(Provider.OpenRouter)
|
||||||
|
.find((m) => m.id === model.model_id)}
|
||||||
|
{@const disabled =
|
||||||
|
onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
|
||||||
|
|
||||||
|
<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 openRouterModel && supportsImages(openRouterModel)}
|
||||||
|
<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 image analysis
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if openRouterModel && supportsReasoning(openRouterModel)}
|
||||||
|
<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}
|
||||||
|
</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)}
|
||||||
|
{@render modelCard(model)}
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
{#each groupedModels as [company, models] (company)}
|
||||||
|
{@const filteredModels = models.filter((m) => !isPinned(m))}
|
||||||
|
{#if filteredModels.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"
|
||||||
|
>
|
||||||
|
{company}
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems class="grid grid-cols-2 gap-3 px-3 pb-3 md:grid-cols-4">
|
||||||
|
{#each filteredModels 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.model_id)}
|
||||||
|
{@const openRouterModel = modelsState
|
||||||
|
.from(Provider.OpenRouter)
|
||||||
|
.find((m) => m.id === model.model_id)}
|
||||||
|
{@const disabled = onlyImageModels && openRouterModel && !supportsImages(openRouterModel)}
|
||||||
|
|
||||||
|
<Command.Item
|
||||||
|
value={model.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.model_id)}
|
||||||
|
>
|
||||||
|
<div class={cn('flex flex-col items-center')}>
|
||||||
|
{#if getModelIcon(model.model_id)}
|
||||||
|
{@const ModelIcon = getModelIcon(model.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 openRouterModel && supportsImages(openRouterModel)}
|
||||||
|
<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 image analysis
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if openRouterModel && supportsReasoning(openRouterModel)}
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-popover absolute top-1 right-1 scale-75 rounded-md p-1 md:opacity-0 transition-all group-hover/item:scale-100 group-hover/item:opacity-100"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7"
|
||||||
|
onclick={(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePin(model._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if isPinned(model)}
|
||||||
|
<PinOffIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<PinIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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,
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
primary: 'bg-primary text-primary-foreground',
|
primary: 'bg-primary text-primary-foreground',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
xs: 'min-w-5 gap-1.5 p-0.5 px-0.5 text-xs',
|
||||||
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
|
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
|
||||||
default: 'min-w-8 gap-1.5 p-1 px-2',
|
default: 'min-w-8 gap-1.5 p-1 px-2',
|
||||||
lg: 'min-w-9 gap-2 p-1 px-3 text-lg',
|
lg: 'min-w-9 gap-2 p-1 px-3 text-lg',
|
||||||
|
|
|
||||||
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}
|
||||||
|
/>
|
||||||
|
|
@ -26,7 +26,7 @@ type PersistedObjOptions<T> = {
|
||||||
syncTabs?: boolean;
|
syncTabs?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createPersistedObj<T extends object>(
|
export function createPersistedObj<T extends Record<string, unknown>>(
|
||||||
key: string,
|
key: string,
|
||||||
initialValue: T,
|
initialValue: T,
|
||||||
options: PersistedObjOptions<T> = {}
|
options: PersistedObjOptions<T> = {}
|
||||||
|
|
@ -37,7 +37,7 @@ export function createPersistedObj<T extends object>(
|
||||||
syncTabs = true,
|
syncTabs = true,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let current = initialValue;
|
let current: Record<string, unknown> = initialValue;
|
||||||
let storage: Storage | undefined;
|
let storage: Storage | undefined;
|
||||||
let subscribe: VoidFunction | undefined;
|
let subscribe: VoidFunction | undefined;
|
||||||
let version = $state(0);
|
let version = $state(0);
|
||||||
|
|
@ -47,7 +47,18 @@ export function createPersistedObj<T extends object>(
|
||||||
const existingValue = storage.getItem(key);
|
const existingValue = storage.getItem(key);
|
||||||
if (existingValue !== null) {
|
if (existingValue !== null) {
|
||||||
const deserialized = deserialize(existingValue);
|
const deserialized = deserialize(existingValue);
|
||||||
if (deserialized) current = deserialized;
|
|
||||||
|
if (deserialized) {
|
||||||
|
// handle keys that were added at a later point in time
|
||||||
|
for (const key of Object.keys(initialValue)) {
|
||||||
|
const initialKeyValue = deserialized[key];
|
||||||
|
if (initialKeyValue === undefined) {
|
||||||
|
deserialized[key] = initialValue[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current = deserialized;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
serialize(initialValue);
|
serialize(initialValue);
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +77,7 @@ export function createPersistedObj<T extends object>(
|
||||||
version += 1;
|
version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserialize(value: string): T | undefined {
|
function deserialize(value: string): Record<string, unknown> | undefined {
|
||||||
try {
|
try {
|
||||||
return serializer.deserialize(value);
|
return serializer.deserialize(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -75,7 +86,7 @@ export function createPersistedObj<T extends object>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(value: T | undefined): void {
|
function serialize(value: Record<string, unknown> | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value != undefined) {
|
if (value != undefined) {
|
||||||
storage?.setItem(key, serializer.serialize(value));
|
storage?.setItem(key, serializer.serialize(value));
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ import { createPersistedObj } from '$lib/spells/persisted-obj.svelte';
|
||||||
export const settings = createPersistedObj('settings', {
|
export const settings = createPersistedObj('settings', {
|
||||||
modelId: undefined as string | undefined,
|
modelId: undefined as string | undefined,
|
||||||
webSearchEnabled: false,
|
webSearchEnabled: false,
|
||||||
|
reasoningEffort: 'low' as 'low' | 'medium' | 'high',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const Provider = {
|
export const Provider = {
|
||||||
OpenRouter: 'openrouter',
|
OpenRouter: 'openrouter',
|
||||||
HuggingFace: 'huggingface',
|
HuggingFace: 'huggingface',
|
||||||
|
|
@ -14,3 +16,21 @@ export type ProviderMeta = {
|
||||||
models?: 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>;
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,10 @@ export function supportsImages(model: OpenRouterModel): boolean {
|
||||||
return model.architecture.input_modalities.includes('image');
|
return model.architecture.input_modalities.includes('image');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function supportsReasoning(model: OpenRouterModel): boolean {
|
||||||
|
return model.supported_parameters.includes('reasoning');
|
||||||
|
}
|
||||||
|
|
||||||
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
|
export function getImageSupportedModels(models: OpenRouterModel[]): OpenRouterModel[] {
|
||||||
return models.filter(supportsImages);
|
return models.filter(supportsImages);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
name: 'Search Messages',
|
name: 'Search Messages',
|
||||||
keys: [cmdOrCtrl, 'K'],
|
keys: [cmdOrCtrl, 'K'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Scroll to bottom',
|
||||||
|
keys: [cmdOrCtrl, 'D'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Open Model Picker',
|
||||||
|
keys: [cmdOrCtrl, 'Shift', 'M'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import PlusIcon from '~icons/lucide/plus';
|
import PlusIcon from '~icons/lucide/plus';
|
||||||
import XIcon from '~icons/lucide/x';
|
import XIcon from '~icons/lucide/x';
|
||||||
import ModelCard from './model-card.svelte';
|
import ModelCard from './model-card.svelte';
|
||||||
|
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
|
||||||
|
|
||||||
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
|
|
@ -32,7 +33,14 @@
|
||||||
|
|
||||||
const freeModelsToggle = new Toggle({
|
const freeModelsToggle = new Toggle({
|
||||||
value: false,
|
value: false,
|
||||||
disabled: false,
|
});
|
||||||
|
|
||||||
|
const reasoningModelsToggle = new Toggle({
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageModelsToggle = new Toggle({
|
||||||
|
value: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let initiallyEnabled = $state<string[]>([]);
|
let initiallyEnabled = $state<string[]>([]);
|
||||||
|
|
@ -48,11 +56,19 @@
|
||||||
const openRouterModels = $derived(
|
const openRouterModels = $derived(
|
||||||
fuzzysearch({
|
fuzzysearch({
|
||||||
haystack: models.from(Provider.OpenRouter).filter((m) => {
|
haystack: models.from(Provider.OpenRouter).filter((m) => {
|
||||||
if (!freeModelsToggle.value) return true;
|
if (freeModelsToggle.value) {
|
||||||
|
if (m.pricing.prompt !== '0') return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (m.pricing.prompt === '0') return true;
|
if (reasoningModelsToggle.value) {
|
||||||
|
if (!supportsReasoning(m)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
if (imageModelsToggle.value) {
|
||||||
|
if (!supportsImages(m)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}),
|
}),
|
||||||
needle: search,
|
needle: search,
|
||||||
property: 'name',
|
property: 'name',
|
||||||
|
|
@ -96,6 +112,24 @@
|
||||||
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
|
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
|
||||||
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
|
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
{...reasoningModelsToggle.trigger}
|
||||||
|
aria-label="Reasoning 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"
|
||||||
|
>
|
||||||
|
Reasoning
|
||||||
|
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
|
||||||
|
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
{...imageModelsToggle.trigger}
|
||||||
|
aria-label="Image 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"
|
||||||
|
>
|
||||||
|
Images
|
||||||
|
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
|
||||||
|
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@
|
||||||
import { session } from '$lib/state/session.svelte.js';
|
import { session } from '$lib/state/session.svelte.js';
|
||||||
import { ResultAsync } from 'neverthrow';
|
import { ResultAsync } from 'neverthrow';
|
||||||
import { getFirstSentence } from '$lib/utils/strings';
|
import { getFirstSentence } from '$lib/utils/strings';
|
||||||
|
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
|
||||||
|
import type { OpenRouterModel } from '$lib/backend/models/open-router';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import EyeIcon from '~icons/lucide/eye';
|
||||||
|
import BrainIcon from '~icons/lucide/brain';
|
||||||
|
|
||||||
type Model = {
|
type Model = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,10 +20,11 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
provider: Provider;
|
|
||||||
model: Model;
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
} & {
|
||||||
|
provider: typeof Provider.OpenRouter;
|
||||||
|
model: OpenRouterModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { provider, model, enabled = false, disabled = false }: Props = $props();
|
let { provider, model, enabled = false, disabled = false }: Props = $props();
|
||||||
|
|
@ -56,9 +62,9 @@
|
||||||
</div>
|
</div>
|
||||||
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
||||||
</div>
|
</div>
|
||||||
<Card.Description
|
<Card.Description>
|
||||||
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
|
{showMore ? fullDescription : (shortDescription ?? fullDescription)}
|
||||||
>
|
</Card.Description>
|
||||||
{#if shortDescription !== null}
|
{#if shortDescription !== null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -70,4 +76,35 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="flex place-items-center gap-1">
|
||||||
|
{#if model && provider === 'openrouter' && 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 image analysis
|
||||||
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if model && provider === 'openrouter' && 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}
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
import { Provider } from '$lib/types';
|
import { Provider, type Annotation } from '$lib/types';
|
||||||
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import { waitUntil } from '@vercel/functions';
|
import { waitUntil } from '@vercel/functions';
|
||||||
import { getSessionCookie } from 'better-auth/cookies';
|
import { getSessionCookie } from 'better-auth/cookies';
|
||||||
|
|
@ -35,6 +35,7 @@ const reqBodySchema = z
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|
@ -184,6 +185,7 @@ async function generateAIResponse({
|
||||||
rulesResultPromise,
|
rulesResultPromise,
|
||||||
userSettingsPromise,
|
userSettingsPromise,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
reasoningEffort,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
|
|
@ -193,6 +195,7 @@ async function generateAIResponse({
|
||||||
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
|
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
|
||||||
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
|
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||||
}) {
|
}) {
|
||||||
log('Starting AI response generation in background', startTime);
|
log('Starting AI response generation in background', startTime);
|
||||||
|
|
||||||
|
|
@ -466,6 +469,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
messages: messagesToSend,
|
messages: messagesToSend,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
reasoning_effort: reasoningEffort,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
|
|
@ -489,8 +493,10 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
log('Background: OpenAI stream created successfully', startTime);
|
log('Background: OpenAI stream created successfully', startTime);
|
||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
|
let reasoning = '';
|
||||||
let chunkCount = 0;
|
let chunkCount = 0;
|
||||||
let generationId: string | null = null;
|
let generationId: string | null = null;
|
||||||
|
const annotations: Annotation[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
|
|
@ -500,8 +506,14 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
|
|
||||||
|
// @ts-expect-error you're wrong
|
||||||
|
reasoning += chunk.choices[0]?.delta?.reasoning || '';
|
||||||
content += chunk.choices[0]?.delta?.content || '';
|
content += chunk.choices[0]?.delta?.content || '';
|
||||||
if (!content) continue;
|
// @ts-expect-error you're wrong
|
||||||
|
annotations.push(...(chunk.choices[0]?.delta?.annotations ?? []));
|
||||||
|
|
||||||
|
if (!content && !reasoning) continue;
|
||||||
|
|
||||||
generationId = chunk.id;
|
generationId = chunk.id;
|
||||||
|
|
||||||
|
|
@ -509,7 +521,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
client.mutation(api.messages.updateContent, {
|
client.mutation(api.messages.updateContent, {
|
||||||
message_id: mid,
|
message_id: mid,
|
||||||
content,
|
content,
|
||||||
|
reasoning: reasoning.length > 0 ? reasoning : undefined,
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
|
generation_id: generationId,
|
||||||
|
annotations,
|
||||||
|
reasoning_effort: reasoningEffort,
|
||||||
}),
|
}),
|
||||||
(e) => `Failed to update message content: ${e}`
|
(e) => `Failed to update message content: ${e}`
|
||||||
);
|
);
|
||||||
|
|
@ -746,6 +762,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
content: args.message,
|
content: args.message,
|
||||||
session_token: args.session_token,
|
session_token: args.session_token,
|
||||||
model_id: args.model_id,
|
model_id: args.model_id,
|
||||||
|
reasoning_effort: args.reasoning_effort,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
images: args.images,
|
images: args.images,
|
||||||
web_search_enabled: args.web_search_enabled,
|
web_search_enabled: args.web_search_enabled,
|
||||||
|
|
@ -792,6 +809,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
rulesResultPromise,
|
rulesResultPromise,
|
||||||
userSettingsPromise,
|
userSettingsPromise,
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
|
reasoningEffort: args.reasoning_effort,
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
log(`Background AI response generation error: ${error}`, startTime);
|
log(`Background AI response generation error: ${error}`, startTime);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
import { settings } from '$lib/state/settings.svelte.js';
|
import { settings } from '$lib/state/settings.svelte.js';
|
||||||
import { Provider } from '$lib/types';
|
import { Provider } from '$lib/types';
|
||||||
import { compressImage } from '$lib/utils/image-compression';
|
import { compressImage } from '$lib/utils/image-compression';
|
||||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
import { supportsImages, supportsReasoning } from '$lib/utils/model-capabilities';
|
||||||
import { omit, pick } from '$lib/utils/object.js';
|
import { omit, pick } from '$lib/utils/object.js';
|
||||||
import { cn } from '$lib/utils/utils.js';
|
import { cn } from '$lib/utils/utils.js';
|
||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
|
@ -38,13 +38,16 @@
|
||||||
import XIcon from '~icons/lucide/x';
|
import XIcon from '~icons/lucide/x';
|
||||||
import { callCancelGeneration } from '../api/cancel-generation/call.js';
|
import { callCancelGeneration } from '../api/cancel-generation/call.js';
|
||||||
import { callGenerateMessage } from '../api/generate-message/call.js';
|
import { callGenerateMessage } from '../api/generate-message/call.js';
|
||||||
import ModelPicker from './model-picker.svelte';
|
import { ModelPicker } from '$lib/components/model-picker';
|
||||||
import SearchModal from './search-modal.svelte';
|
import SearchModal from './search-modal.svelte';
|
||||||
import { shortcut } from '$lib/actions/shortcut.svelte.js';
|
import { shortcut } from '$lib/actions/shortcut.svelte.js';
|
||||||
import { mergeAttrs } from 'melt';
|
import { mergeAttrs } from 'melt';
|
||||||
import { callEnhancePrompt } from '../api/enhance-prompt/call.js';
|
import { callEnhancePrompt } from '../api/enhance-prompt/call.js';
|
||||||
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||||
import SparkleIcon from '~icons/lucide/sparkle';
|
import SparkleIcon from '~icons/lucide/sparkle';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import BrainIcon from '~icons/lucide/brain';
|
||||||
|
import * as casing from '$lib/utils/casing.js';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
|
@ -127,6 +130,7 @@
|
||||||
model_id: settings.modelId,
|
model_id: settings.modelId,
|
||||||
images: imagesCopy.length > 0 ? imagesCopy : undefined,
|
images: imagesCopy.length > 0 ? imagesCopy : undefined,
|
||||||
web_search_enabled: settings.webSearchEnabled,
|
web_search_enabled: settings.webSearchEnabled,
|
||||||
|
reasoning_effort: currentModelSupportsReasoning ? settings.reasoningEffort : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.isErr()) {
|
if (res.isErr()) {
|
||||||
|
|
@ -214,6 +218,14 @@
|
||||||
return currentModel ? supportsImages(currentModel) : false;
|
return currentModel ? supportsImages(currentModel) : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentModelSupportsReasoning = $derived.by(() => {
|
||||||
|
if (!settings.modelId) return false;
|
||||||
|
const openRouterModels = models.from(Provider.OpenRouter);
|
||||||
|
const currentModel = openRouterModels.find((m) => m.id === settings.modelId);
|
||||||
|
if (!currentModel) return false;
|
||||||
|
return supportsReasoning(currentModel);
|
||||||
|
});
|
||||||
|
|
||||||
const fileUpload = new FileUpload({
|
const fileUpload = new FileUpload({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
accept: 'image/*',
|
accept: 'image/*',
|
||||||
|
|
@ -697,6 +709,32 @@
|
||||||
<div class="flex flex-wrap items-center gap-2 pr-2">
|
<div class="flex flex-wrap items-center gap-2 pr-2">
|
||||||
<ModelPicker onlyImageModels={selectedImages.length > 0} />
|
<ModelPicker onlyImageModels={selectedImages.length > 0} />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
{#if currentModelSupportsReasoning}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
|
||||||
|
>
|
||||||
|
<BrainIcon class="!size-3" />
|
||||||
|
<span class="hidden whitespace-nowrap sm:inline">
|
||||||
|
{casing.camelToPascal(settings.reasoningEffort)}
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="start">
|
||||||
|
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'high')}>
|
||||||
|
<BrainIcon class="size-4" />
|
||||||
|
High
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'medium')}>
|
||||||
|
<BrainIcon class="size-4" />
|
||||||
|
Medium
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onSelect={() => (settings.reasoningEffort = 'low')}>
|
||||||
|
<BrainIcon class="size-4" />
|
||||||
|
Low
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={cn(
|
class={cn(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
import { last } from '$lib/utils/array';
|
import { last } from '$lib/utils/array';
|
||||||
import { settings } from '$lib/state/settings.svelte';
|
import { settings } from '$lib/state/settings.svelte';
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||||
|
import GlobeIcon from '~icons/lucide/globe';
|
||||||
|
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
||||||
|
|
||||||
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
|
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
|
||||||
conversation_id: page.params.id ?? '',
|
conversation_id: page.params.id ?? '',
|
||||||
|
|
@ -21,6 +24,8 @@
|
||||||
session_token: session.current?.session.token ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const lastMessage = $derived(messages?.data?.[messages.data?.length - 1] ?? null);
|
||||||
|
|
||||||
const lastMessageHasContent = $derived.by(() => {
|
const lastMessageHasContent = $derived.by(() => {
|
||||||
if (!messages.data) return false;
|
if (!messages.data) return false;
|
||||||
const lastMessage = messages.data[messages.data.length - 1];
|
const lastMessage = messages.data[messages.data.length - 1];
|
||||||
|
|
@ -32,6 +37,15 @@
|
||||||
return lastMessage.content.length > 0;
|
return lastMessage.content.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lastMessageHasReasoning = $derived.by(() => {
|
||||||
|
if (!messages.data) return false;
|
||||||
|
const lastMessage = messages.data[messages.data.length - 1];
|
||||||
|
|
||||||
|
if (!lastMessage) return false;
|
||||||
|
|
||||||
|
return lastMessage.reasoning?.length ?? 0 > 0;
|
||||||
|
});
|
||||||
|
|
||||||
let changedRoute = $state(false);
|
let changedRoute = $state(false);
|
||||||
watch(
|
watch(
|
||||||
() => page.params.id,
|
() => page.params.id,
|
||||||
|
|
@ -74,8 +88,23 @@
|
||||||
{#each messages.data ?? [] as message (message._id)}
|
{#each messages.data ?? [] as message (message._id)}
|
||||||
<Message {message} />
|
<Message {message} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if conversation.data?.generating && !lastMessageHasContent}
|
{#if conversation.data?.generating}
|
||||||
<LoadingDots />
|
{#if lastMessage?.web_search_enabled}
|
||||||
|
{#if lastMessage?.annotations === undefined || lastMessage?.annotations?.length === 0}
|
||||||
|
<div class="flex place-items-center gap-2">
|
||||||
|
<GlobeIcon class="inline size-4 shrink-0" />
|
||||||
|
<ShinyText class="text-muted-foreground text-sm">Searching the web...</ShinyText>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if !lastMessageHasReasoning && !lastMessageHasContent}
|
||||||
|
<LoadingDots />
|
||||||
|
{:else}
|
||||||
|
<div class="flex place-items-center gap-2">
|
||||||
|
<div class="flex animate-[spin_0.65s_linear_infinite] place-items-center justify-center">
|
||||||
|
<LoaderCircleIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@
|
||||||
import { callGenerateMessage } from '../../api/generate-message/call';
|
import { callGenerateMessage } from '../../api/generate-message/call';
|
||||||
import * as Icons from '$lib/components/icons';
|
import * as Icons from '$lib/components/icons';
|
||||||
import { settings } from '$lib/state/settings.svelte';
|
import { settings } from '$lib/state/settings.svelte';
|
||||||
|
import ShinyText from '$lib/components/animations/shiny-text.svelte';
|
||||||
|
import ChevronRightIcon from '~icons/lucide/chevron-right';
|
||||||
|
import { AnnotationSchema, type Annotation } from '$lib/types';
|
||||||
|
import ExternalLinkIcon from '~icons/lucide/external-link';
|
||||||
|
import GlobeIcon from '~icons/lucide/globe';
|
||||||
|
import { Avatar } from 'melt/components';
|
||||||
|
import BrainIcon from '~icons/lucide/brain';
|
||||||
|
import * as casing from '$lib/utils/casing';
|
||||||
|
|
||||||
const style = tv({
|
const style = tv({
|
||||||
base: 'prose rounded-xl p-2 max-w-full',
|
base: 'prose rounded-xl p-2 max-w-full',
|
||||||
|
|
@ -86,9 +94,27 @@
|
||||||
|
|
||||||
await goto(`/chat/${cid}`);
|
await goto(`/chat/${cid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotations = $derived.by(() => {
|
||||||
|
if (!message.annotations || message.annotations.length === 0) return null;
|
||||||
|
|
||||||
|
const annotations: Annotation[] = [];
|
||||||
|
|
||||||
|
for (const annotation of message.annotations) {
|
||||||
|
const parsed = AnnotationSchema.safeParse(annotation);
|
||||||
|
|
||||||
|
if (!parsed.success) continue;
|
||||||
|
|
||||||
|
annotations.push(parsed.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return annotations;
|
||||||
|
});
|
||||||
|
|
||||||
|
let showReasoning = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
|
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && message.reasoning?.length === 0 && !message.error)}
|
||||||
<div
|
<div
|
||||||
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
|
class={cn('group flex flex-col gap-1', { 'max-w-[80%] self-end ': message.role === 'user' })}
|
||||||
{@attach (node) => {
|
{@attach (node) => {
|
||||||
|
|
@ -123,6 +149,28 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if message.reasoning}
|
||||||
|
<div class="my-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-muted-foreground flex items-center gap-1 pb-2 text-sm"
|
||||||
|
aria-label="Toggle reasoning"
|
||||||
|
onclick={() => (showReasoning = !showReasoning)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon class={cn('inline size-4', { 'rotate-90': showReasoning })} />
|
||||||
|
{#if message.content.length === 0}
|
||||||
|
<ShinyText>Reasoning...</ShinyText>
|
||||||
|
{:else}
|
||||||
|
<span>Reasoning</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if showReasoning}
|
||||||
|
<div class="text-muted-foreground/50 bg-popover relative rounded-lg p-2 text-xs">
|
||||||
|
{message.reasoning}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class={style({ role: message.role })}>
|
<div class={style({ role: message.role })}>
|
||||||
{#if message.error}
|
{#if message.error}
|
||||||
<div class="text-destructive">
|
<div class="text-destructive">
|
||||||
|
|
@ -146,6 +194,58 @@
|
||||||
</svelte:boundary>
|
</svelte:boundary>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if annotations}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-muted-foreground pl-2 text-xs">
|
||||||
|
{annotations.length}
|
||||||
|
{annotations.length === 1 ? 'Citation' : 'Citations'}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#each annotations as annotation}
|
||||||
|
{#if annotation.type === 'url_citation'}
|
||||||
|
{@const url = new URL(annotation.url_citation.url)}
|
||||||
|
<a
|
||||||
|
href={annotation.url_citation.url}
|
||||||
|
target="_blank"
|
||||||
|
class="border-border bg-background bg-noise -m-1 flex place-items-center justify-center rounded-full border p-0.5 transition-transform hover:scale-110"
|
||||||
|
>
|
||||||
|
{@render siteIcon({ url })}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scrollbar-hide flex place-items-center gap-2 overflow-x-auto p-2">
|
||||||
|
{#each annotations as annotation}
|
||||||
|
{#if annotation.type === 'url_citation'}
|
||||||
|
{@const url = new URL(annotation.url_citation.url)}
|
||||||
|
<div
|
||||||
|
class="border-border hover:border-primary/50 text-muted-foreground group relative flex h-32 min-w-60 flex-col justify-between rounded-lg border p-4 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={annotation.url_citation.url}
|
||||||
|
target="_blank"
|
||||||
|
class="group-hover:text-foreground block max-w-full truncate font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span class="absolute inset-0"></span>
|
||||||
|
{annotation.url_citation.title}
|
||||||
|
</a>
|
||||||
|
<p class="truncate text-sm">
|
||||||
|
{annotation.url_citation.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="flex items-center gap-2 text-xs">
|
||||||
|
{@render siteIcon({ url })}
|
||||||
|
{url.hostname}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ExternalLinkIcon class="text-primary absolute top-2 right-2 size-3" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
|
'flex place-items-center gap-2 transition-opacity group-hover:opacity-100 md:opacity-0',
|
||||||
|
|
@ -188,8 +288,16 @@
|
||||||
{#if message.model_id !== undefined}
|
{#if message.model_id !== undefined}
|
||||||
<span class="text-muted-foreground text-xs">{message.model_id}</span>
|
<span class="text-muted-foreground text-xs">{message.model_id}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if message.reasoning_effort}
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
<BrainIcon class="inline-block size-4 shrink-0 text-green-500" />
|
||||||
|
{casing.camelToPascal(message.reasoning_effort)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if message.web_search_enabled}
|
{#if message.web_search_enabled}
|
||||||
<span class="text-muted-foreground text-xs"> Web search enabled </span>
|
<span class="text-muted-foreground text-xs">
|
||||||
|
<GlobeIcon class="text-primary inline-block size-4 shrink-0" />
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if message.cost_usd !== undefined}
|
{#if message.cost_usd !== undefined}
|
||||||
|
|
@ -209,3 +317,14 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet siteIcon({ url }: { url: URL })}
|
||||||
|
<Avatar src={`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`}>
|
||||||
|
{#snippet children(avatar)}
|
||||||
|
<img {...avatar.image} alt={`${url.hostname} site icon`} />
|
||||||
|
<span {...avatar.fallback}>
|
||||||
|
<GlobeIcon class="inline-block size-4 shrink-0" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Avatar>
|
||||||
|
{/snippet}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
Loading…
Add table
Reference in a new issue