commit
89c6fea1e5
38 changed files with 1188 additions and 391 deletions
|
|
@ -4,3 +4,6 @@ pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
bun.lock
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|
||||||
|
# Convex formats this
|
||||||
|
convex.json
|
||||||
|
|
@ -20,7 +20,16 @@ export default ts.config(
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: { ...globals.browser, ...globals.node },
|
globals: { ...globals.browser, ...globals.node },
|
||||||
},
|
},
|
||||||
rules: { 'no-undef': 'off' },
|
rules: {
|
||||||
|
'no-undef': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@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",
|
||||||
"bits-ui": "^2.6.2",
|
"@vercel/functions": "^2.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"convex": "^1.24.8",
|
"convex": "^1.24.8",
|
||||||
|
|
|
||||||
68
pnpm-lock.yaml
generated
68
pnpm-lock.yaml
generated
|
|
@ -63,9 +63,9 @@ importers:
|
||||||
'@testing-library/svelte':
|
'@testing-library/svelte':
|
||||||
specifier: ^5.2.4
|
specifier: ^5.2.4
|
||||||
version: 5.2.8(svelte@5.34.1)(vite@6.3.5(@types/node@24.0.1)(jiti@2.4.2)(lightningcss@1.30.1))(vitest@3.2.3(@types/node@24.0.1)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1))
|
version: 5.2.8(svelte@5.34.1)(vite@6.3.5(@types/node@24.0.1)(jiti@2.4.2)(lightningcss@1.30.1))(vitest@3.2.3(@types/node@24.0.1)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1))
|
||||||
bits-ui:
|
'@vercel/functions':
|
||||||
specifier: ^2.6.2
|
specifier: ^2.2.0
|
||||||
version: 2.6.2(@internationalized/date@3.8.2)(svelte@5.34.1)
|
version: 2.2.0
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
|
@ -620,9 +620,6 @@ 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'}
|
||||||
|
|
@ -838,9 +835,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -1035,6 +1029,15 @@ packages:
|
||||||
resolution: {integrity: sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==}
|
resolution: {integrity: sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@vercel/functions@2.2.0':
|
||||||
|
resolution: {integrity: sha512-x1Zrc2jOclTSB9+Ic/XNMDinO0SG4ZS5YeV2Xz1m/tuJOM7QtPVU3Epw2czBao0dukefmC8HCNpyUL8ZchJ/Tg==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@aws-sdk/credential-provider-web-identity': '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@aws-sdk/credential-provider-web-identity':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/expect@3.2.3':
|
'@vitest/expect@3.2.3':
|
||||||
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
|
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
|
||||||
|
|
||||||
|
|
@ -1128,13 +1131,6 @@ packages:
|
||||||
better-call@1.0.9:
|
better-call@1.0.9:
|
||||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||||
|
|
||||||
bits-ui@2.6.2:
|
|
||||||
resolution: {integrity: sha512-OlPSUAT+ENhtRarPjABljca1cCljyoAqOZKfgjCB8PxQii2fL0AKnzObhnEdhZKwYdpXczEtNOYqUUNYwliaWA==}
|
|
||||||
engines: {node: '>=20', pnpm: '>=8.7.0'}
|
|
||||||
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==}
|
||||||
|
|
||||||
|
|
@ -2180,12 +2176,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
svelte-toolbelt@0.9.1:
|
|
||||||
resolution: {integrity: sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g==}
|
|
||||||
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'}
|
||||||
|
|
@ -2193,9 +2183,6 @@ 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==}
|
||||||
|
|
||||||
|
|
@ -2831,10 +2818,6 @@ 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
|
||||||
|
|
@ -3040,10 +3023,6 @@ 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
|
||||||
|
|
@ -3257,6 +3236,8 @@ snapshots:
|
||||||
'@typescript-eslint/types': 8.34.0
|
'@typescript-eslint/types': 8.34.0
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
|
'@vercel/functions@2.2.0': {}
|
||||||
|
|
||||||
'@vitest/expect@3.2.3':
|
'@vitest/expect@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
|
|
@ -3366,18 +3347,6 @@ snapshots:
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
uncrypto: 0.1.3
|
uncrypto: 0.1.3
|
||||||
|
|
||||||
bits-ui@2.6.2(@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
|
|
||||||
css.escape: 1.5.1
|
|
||||||
esm-env: 1.2.2
|
|
||||||
runed: 0.28.0(svelte@5.34.1)
|
|
||||||
svelte: 5.34.1
|
|
||||||
svelte-toolbelt: 0.9.1(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
|
||||||
|
|
@ -4341,13 +4310,6 @@ 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.1(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
|
||||||
|
|
@ -4367,8 +4329,6 @@ 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: {}
|
||||||
|
|
|
||||||
14
src/app.css
14
src/app.css
|
|
@ -3,7 +3,7 @@
|
||||||
@import '@fontsource-variable/geist-mono';
|
@import '@fontsource-variable/geist-mono';
|
||||||
@import '@fontsource-variable/fraunces';
|
@import '@fontsource-variable/fraunces';
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(0.9754 0.0084 325.6414);
|
--background: oklch(0.9754 0.0084 325.6414);
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
--muted-foreground: oklch(0.794 0.0372 307.1032);
|
--muted-foreground: oklch(0.794 0.0372 307.1032);
|
||||||
--accent: oklch(0.3649 0.0508 308.4911);
|
--accent: oklch(0.3649 0.0508 308.4911);
|
||||||
--accent-foreground: oklch(0.9647 0.0091 341.8035);
|
--accent-foreground: oklch(0.9647 0.0091 341.8035);
|
||||||
--destructive: oklch(0.2258 0.0524 12.6119);
|
--destructive: oklch(0.5248 0.1368 20.8317);
|
||||||
--destructive-foreground: oklch(1 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
--border: oklch(0.3286 0.0154 343.4461);
|
--border: oklch(0.3286 0.0154 343.4461);
|
||||||
--input: oklch(0.3387 0.0195 332.8347);
|
--input: oklch(0.3387 0.0195 332.8347);
|
||||||
|
|
@ -229,3 +229,13 @@
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* For components that need horizontal scrolling */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* Internet Explorer and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, and Opera */
|
||||||
|
}
|
||||||
|
|
|
||||||
211
src/lib/actions/shortcut.svelte.ts
Normal file
211
src/lib/actions/shortcut.svelte.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
/*
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createAttachmentKey } from 'svelte/attachments';
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
/** Event to use to detect the shortcut @default 'keydown' */
|
||||||
|
event?: 'keydown' | 'keyup' | 'keypress';
|
||||||
|
/** Function to be called when the shortcut is pressed */
|
||||||
|
callback: (e: KeyboardEvent) => void;
|
||||||
|
/** Should the `Shift` key be pressed */
|
||||||
|
shift?: boolean;
|
||||||
|
/** Should the `Ctrl` / `Command` key be pressed */
|
||||||
|
ctrl?: boolean;
|
||||||
|
/** Should the `Alt` key be pressed */
|
||||||
|
alt?: boolean;
|
||||||
|
/** Which key should be pressed */
|
||||||
|
key: Key;
|
||||||
|
/** Control whether or not the shortcut prevents default behavior @default true */
|
||||||
|
preventDefault?: boolean;
|
||||||
|
/** Control whether or not the shortcut stops propagation @default false */
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Allows you to configure one or more shortcuts based on the key events of an element.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
* ```svelte
|
||||||
|
* <!-- Ctrl + K Shortcut -->
|
||||||
|
* <svelte:window use:shortcut={
|
||||||
|
* {
|
||||||
|
* ctrl: true,
|
||||||
|
* key: 'k',
|
||||||
|
* callback: commandMenu.toggle
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const shortcut = (node: HTMLElement, options: Options[] | Options) => {
|
||||||
|
const handleKeydown = (e: KeyboardEvent, options: Options) => {
|
||||||
|
if (options.ctrl && !e.ctrlKey && !e.metaKey) return;
|
||||||
|
|
||||||
|
if (options.alt && !e.altKey) return;
|
||||||
|
|
||||||
|
if (options.shift && !e.shiftKey) return;
|
||||||
|
|
||||||
|
if (e.key.toLocaleLowerCase() !== options.key.toLocaleLowerCase()) return;
|
||||||
|
|
||||||
|
if (options.preventDefault === undefined || options.preventDefault) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.stopPropagation) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
options.callback(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let optionsArr: Options[] = [];
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
optionsArr = options;
|
||||||
|
} else {
|
||||||
|
optionsArr = [options];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opt of optionsArr) {
|
||||||
|
node.addEventListener(opt.event ?? 'keydown', (e) => handleKeydown(e, opt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const opt of optionsArr) {
|
||||||
|
node.removeEventListener(opt.event ?? 'keydown', (e) => handleKeydown(e, opt));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Allows you to configure one or more shortcuts based on the key events of an element.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
* ```svelte
|
||||||
|
* <!-- Ctrl + K Shortcut -->
|
||||||
|
* <svelte:window
|
||||||
|
* {...attachShortcut({
|
||||||
|
* ctrl: true,
|
||||||
|
* key: 'k',
|
||||||
|
* callback: commandMenu.toggle
|
||||||
|
* })}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function attachShortcut(opts: Options[] | Options) {
|
||||||
|
return {
|
||||||
|
[createAttachmentKey()]: (node: HTMLElement) => shortcut(node, opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Key =
|
||||||
|
| 'backspace'
|
||||||
|
| 'tab'
|
||||||
|
| 'enter'
|
||||||
|
| 'shift(left)'
|
||||||
|
| 'shift(right)'
|
||||||
|
| 'ctrl(left)'
|
||||||
|
| 'ctrl(right)'
|
||||||
|
| 'alt(left)'
|
||||||
|
| 'alt(right)'
|
||||||
|
| 'pause/break'
|
||||||
|
| 'caps lock'
|
||||||
|
| 'escape'
|
||||||
|
| 'space'
|
||||||
|
| 'page up'
|
||||||
|
| 'page down'
|
||||||
|
| 'end'
|
||||||
|
| 'home'
|
||||||
|
| 'left arrow'
|
||||||
|
| 'up arrow'
|
||||||
|
| 'right arrow'
|
||||||
|
| 'down arrow'
|
||||||
|
| 'print screen'
|
||||||
|
| 'insert'
|
||||||
|
| 'delete'
|
||||||
|
| '0'
|
||||||
|
| '1'
|
||||||
|
| '2'
|
||||||
|
| '3'
|
||||||
|
| '4'
|
||||||
|
| '5'
|
||||||
|
| '6'
|
||||||
|
| '7'
|
||||||
|
| '8'
|
||||||
|
| '9'
|
||||||
|
| 'a'
|
||||||
|
| 'b'
|
||||||
|
| 'c'
|
||||||
|
| 'd'
|
||||||
|
| 'e'
|
||||||
|
| 'f'
|
||||||
|
| 'g'
|
||||||
|
| 'h'
|
||||||
|
| 'i'
|
||||||
|
| 'j'
|
||||||
|
| 'k'
|
||||||
|
| 'l'
|
||||||
|
| 'm'
|
||||||
|
| 'n'
|
||||||
|
| 'o'
|
||||||
|
| 'p'
|
||||||
|
| 'q'
|
||||||
|
| 'r'
|
||||||
|
| 's'
|
||||||
|
| 't'
|
||||||
|
| 'u'
|
||||||
|
| 'v'
|
||||||
|
| 'w'
|
||||||
|
| 'x'
|
||||||
|
| 'y'
|
||||||
|
| 'z'
|
||||||
|
| 'left window key'
|
||||||
|
| 'right window key'
|
||||||
|
| 'select key (Context Menu)'
|
||||||
|
| 'numpad 0'
|
||||||
|
| 'numpad 1'
|
||||||
|
| 'numpad 2'
|
||||||
|
| 'numpad 3'
|
||||||
|
| 'numpad 4'
|
||||||
|
| 'numpad 5'
|
||||||
|
| 'numpad 6'
|
||||||
|
| 'numpad 7'
|
||||||
|
| 'numpad 8'
|
||||||
|
| 'numpad 9'
|
||||||
|
| 'multiply'
|
||||||
|
| 'add'
|
||||||
|
| 'subtract'
|
||||||
|
| 'decimal point'
|
||||||
|
| 'divide'
|
||||||
|
| 'f1'
|
||||||
|
| 'f2'
|
||||||
|
| 'f3'
|
||||||
|
| 'f4'
|
||||||
|
| 'f5'
|
||||||
|
| 'f6'
|
||||||
|
| 'f7'
|
||||||
|
| 'f8'
|
||||||
|
| 'f9'
|
||||||
|
| 'f10'
|
||||||
|
| 'f11'
|
||||||
|
| 'f12'
|
||||||
|
| 'num lock'
|
||||||
|
| 'scroll lock'
|
||||||
|
| 'audio volume mute'
|
||||||
|
| 'audio volume down'
|
||||||
|
| 'audio volume up'
|
||||||
|
| 'media player'
|
||||||
|
| 'launch application 1'
|
||||||
|
| 'launch application 2'
|
||||||
|
| 'semi-colon'
|
||||||
|
| 'equal sign'
|
||||||
|
| 'comma'
|
||||||
|
| 'dash'
|
||||||
|
| 'period'
|
||||||
|
| 'forward slash'
|
||||||
|
| 'Backquote/Grave accent'
|
||||||
|
| 'open bracket'
|
||||||
|
| 'back slash'
|
||||||
|
| 'close bracket'
|
||||||
|
| 'single quote';
|
||||||
|
|
@ -14,14 +14,12 @@ export const auth = betterAuth({
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// databaseHooks: {
|
databaseHooks: {
|
||||||
// user: {
|
user: {
|
||||||
// create: {
|
create: {
|
||||||
// after: async ({ user }) => {
|
after: async (_user) => {},
|
||||||
// // TODO: automatically enable default models for the user
|
},
|
||||||
// },
|
},
|
||||||
// },
|
},
|
||||||
// },
|
|
||||||
// },
|
|
||||||
plugins: [],
|
plugins: [],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
import { api } from './_generated/api';
|
import { api } from './_generated/api';
|
||||||
import { messageRoleValidator, providerValidator } from './schema';
|
import { messageRoleValidator, providerValidator } from './schema';
|
||||||
import { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
export const getAllFromConversation = query({
|
export const getAllFromConversation = query({
|
||||||
args: {
|
args: {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ export const messageRoleValidator = v.union(
|
||||||
|
|
||||||
export type MessageRole = Infer<typeof messageRoleValidator>;
|
export type MessageRole = Infer<typeof messageRoleValidator>;
|
||||||
|
|
||||||
|
export const ruleAttachValidator = v.union(v.literal('always'), v.literal('manual'));
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
user_keys: defineTable({
|
user_keys: defineTable({
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
|
|
@ -30,6 +32,15 @@ export default defineSchema({
|
||||||
.index('by_model_provider', ['model_id', 'provider'])
|
.index('by_model_provider', ['model_id', 'provider'])
|
||||||
.index('by_provider_user', ['provider', 'user_id'])
|
.index('by_provider_user', ['provider', 'user_id'])
|
||||||
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
|
.index('by_model_provider_user', ['model_id', 'provider', 'user_id']),
|
||||||
|
user_rules: defineTable({
|
||||||
|
user_id: v.string(),
|
||||||
|
name: v.string(),
|
||||||
|
attach: ruleAttachValidator,
|
||||||
|
rule: v.string(),
|
||||||
|
})
|
||||||
|
.index('by_user', ['user_id'])
|
||||||
|
.index('by_user_attach', ['user_id', 'attach'])
|
||||||
|
.index('by_user_name', ['user_id', 'name']),
|
||||||
conversations: defineTable({
|
conversations: defineTable({
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
title: v.string(),
|
title: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as array from '../../utils/array';
|
||||||
import * as object from '../../utils/object';
|
import * as object from '../../utils/object';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import { Provider } from '../../types';
|
import { Provider } from '../../types';
|
||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
|
||||||
export const getModelKey = (args: { provider: Provider; model_id: string }) => {
|
export const getModelKey = (args: { provider: Provider; model_id: string }) => {
|
||||||
return `${args.provider}:${args.model_id}`;
|
return `${args.provider}:${args.model_id}`;
|
||||||
|
|
@ -12,12 +13,18 @@ export const getModelKey = (args: { provider: Provider; model_id: string }) => {
|
||||||
|
|
||||||
export const get_enabled = query({
|
export const get_enabled = query({
|
||||||
args: {
|
args: {
|
||||||
user_id: v.string(),
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<Record<string, Doc<'user_enabled_models'>>> => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
const models = await ctx.db
|
const models = await ctx.db
|
||||||
.query('user_enabled_models')
|
.query('user_enabled_models')
|
||||||
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
|
.withIndex('by_user', (q) => q.eq('user_id', session.userId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
return array.toMap(models, (m) => [getModelKey(m), m]);
|
return array.toMap(models, (m) => [getModelKey(m), m]);
|
||||||
|
|
@ -26,15 +33,21 @@ export const get_enabled = query({
|
||||||
|
|
||||||
export const is_enabled = query({
|
export const is_enabled = query({
|
||||||
args: {
|
args: {
|
||||||
user_id: v.string(),
|
sessionToken: v.string(),
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
model_id: v.string(),
|
model_id: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<boolean> => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.sessionToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
const model = await ctx.db
|
const model = await ctx.db
|
||||||
.query('user_enabled_models')
|
.query('user_enabled_models')
|
||||||
.withIndex('by_model_provider_user', (q) =>
|
.withIndex('by_model_provider_user', (q) =>
|
||||||
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', args.user_id)
|
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', session.userId)
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
|
@ -46,13 +59,19 @@ export const get = query({
|
||||||
args: {
|
args: {
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
model_id: v.string(),
|
model_id: v.string(),
|
||||||
user_id: v.string(),
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<Doc<'user_enabled_models'> | null> => {
|
||||||
|
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
|
const model = await ctx.db
|
||||||
.query('user_enabled_models')
|
.query('user_enabled_models')
|
||||||
.withIndex('by_model_provider_user', (q) =>
|
.withIndex('by_model_provider_user', (q) =>
|
||||||
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', args.user_id)
|
q.eq('model_id', args.model_id).eq('provider', args.provider).eq('user_id', session.userId)
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
|
@ -64,7 +83,6 @@ export const set = mutation({
|
||||||
args: {
|
args: {
|
||||||
provider: providerValidator,
|
provider: providerValidator,
|
||||||
model_id: v.string(),
|
model_id: v.string(),
|
||||||
user_id: v.string(),
|
|
||||||
enabled: v.boolean(),
|
enabled: v.boolean(),
|
||||||
session_token: v.string(),
|
session_token: v.string(),
|
||||||
},
|
},
|
||||||
|
|
@ -73,9 +91,7 @@ export const set = mutation({
|
||||||
sessionToken: args.session_token,
|
sessionToken: args.session_token,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) throw new Error('Invalid session token');
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query('user_enabled_models')
|
.query('user_enabled_models')
|
||||||
|
|
@ -90,7 +106,8 @@ export const set = mutation({
|
||||||
await ctx.db.delete(existing._id);
|
await ctx.db.delete(existing._id);
|
||||||
} else {
|
} else {
|
||||||
await ctx.db.insert('user_enabled_models', {
|
await ctx.db.insert('user_enabled_models', {
|
||||||
...object.pick(args, ['provider', 'model_id', 'user_id']),
|
...object.pick(args, ['provider', 'model_id']),
|
||||||
|
user_id: session.userId,
|
||||||
pinned: null,
|
pinned: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
src/lib/backend/convex/user_rules.ts
Normal file
102
src/lib/backend/convex/user_rules.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { ruleAttachValidator } from './schema';
|
||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
name: v.string(),
|
||||||
|
attach: ruleAttachValidator,
|
||||||
|
rule: v.string(),
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('user_rules')
|
||||||
|
.withIndex('by_user_name', (q) => q.eq('user_id', session.userId).eq('name', args.name))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) throw new Error('Rule with this name already exists');
|
||||||
|
|
||||||
|
await ctx.db.insert('user_rules', {
|
||||||
|
user_id: session.userId,
|
||||||
|
name: args.name,
|
||||||
|
attach: args.attach,
|
||||||
|
rule: args.rule,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
ruleId: v.id('user_rules'),
|
||||||
|
attach: ruleAttachValidator,
|
||||||
|
rule: v.string(),
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
|
const existing = await ctx.db.get(args.ruleId);
|
||||||
|
|
||||||
|
if (!existing) throw new Error('Rule not found');
|
||||||
|
if (existing.user_id !== session.userId) throw new Error('You are not the owner of this rule');
|
||||||
|
|
||||||
|
await ctx.db.patch(args.ruleId, {
|
||||||
|
attach: args.attach,
|
||||||
|
rule: args.rule,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
ruleId: v.id('user_rules'),
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
|
const existing = await ctx.db.get(args.ruleId);
|
||||||
|
|
||||||
|
if (!existing) throw new Error('Rule not found');
|
||||||
|
if (existing.user_id !== session.userId) throw new Error('You are not the owner of this rule');
|
||||||
|
|
||||||
|
await ctx.db.delete(args.ruleId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const all = query({
|
||||||
|
args: {
|
||||||
|
session_token: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Doc<'user_rules'>[]> => {
|
||||||
|
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
||||||
|
sessionToken: args.session_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) throw new Error('Invalid session token');
|
||||||
|
|
||||||
|
const allRules = await ctx.db
|
||||||
|
.query('user_rules')
|
||||||
|
.withIndex('by_user', (q) => q.eq('user_id', session.userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return allRules;
|
||||||
|
},
|
||||||
|
});
|
||||||
4
src/lib/cache/cached-query.svelte.ts
vendored
4
src/lib/cache/cached-query.svelte.ts
vendored
|
|
@ -3,14 +3,14 @@ import { SessionStorageCache } from './session-cache.js';
|
||||||
import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
|
import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
|
||||||
import { extract, watch } from 'runed';
|
import { extract, watch } from 'runed';
|
||||||
|
|
||||||
interface CachedQueryOptions {
|
export interface CachedQueryOptions {
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
staleWhileRevalidate?: boolean;
|
staleWhileRevalidate?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueryResult<T> {
|
export interface QueryResult<T> {
|
||||||
data: T | undefined;
|
data: T | undefined;
|
||||||
error: Error | undefined;
|
error: Error | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
|
||||||
6
src/lib/cache/session-cache.ts
vendored
6
src/lib/cache/session-cache.ts
vendored
|
|
@ -13,11 +13,7 @@ export class SessionStorageCache<T = unknown> {
|
||||||
private debounceMs: number;
|
private debounceMs: number;
|
||||||
private pendingWrites = new Set<string>();
|
private pendingWrites = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(storageKey = 'query-cache', maxSizeBytes = 1024 * 1024, debounceMs = 300) {
|
||||||
storageKey = 'query-cache',
|
|
||||||
maxSizeBytes = 1024 * 1024,
|
|
||||||
debounceMs = 300
|
|
||||||
) {
|
|
||||||
this.storageKey = storageKey;
|
this.storageKey = storageKey;
|
||||||
this.debounceMs = debounceMs;
|
this.debounceMs = debounceMs;
|
||||||
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
|
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import type { WithChildren, WithoutChildren } from 'bits-ui';
|
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { type VariantProps, tv } from 'tailwind-variants';
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
|
@ -36,7 +35,7 @@
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
export type ButtonPropsWithoutHTML = WithChildren<{
|
export type ButtonPropsWithoutHTML = {
|
||||||
ref?: HTMLElement | null;
|
ref?: HTMLElement | null;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
|
|
@ -46,17 +45,18 @@
|
||||||
currentTarget: EventTarget & HTMLButtonElement;
|
currentTarget: EventTarget & HTMLButtonElement;
|
||||||
}
|
}
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}>;
|
children?: Snippet<[]>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnchorElementProps = ButtonPropsWithoutHTML &
|
export type AnchorElementProps = ButtonPropsWithoutHTML &
|
||||||
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
|
Omit<HTMLAnchorAttributes, 'href' | 'type' | 'children'> & {
|
||||||
href: HTMLAnchorAttributes['href'];
|
href: HTMLAnchorAttributes['href'];
|
||||||
type?: never;
|
type?: never;
|
||||||
disabled?: HTMLButtonAttributes['disabled'];
|
disabled?: HTMLButtonAttributes['disabled'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ButtonElementProps = ButtonPropsWithoutHTML &
|
export type ButtonElementProps = ButtonPropsWithoutHTML &
|
||||||
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
|
Omit<HTMLButtonAttributes, 'type' | 'href' | 'children'> & {
|
||||||
type?: HTMLButtonAttributes['type'];
|
type?: HTMLButtonAttributes['type'];
|
||||||
href?: never;
|
href?: never;
|
||||||
disabled?: HTMLButtonAttributes['disabled'];
|
disabled?: HTMLButtonAttributes['disabled'];
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils/utils.js';
|
import { cn } from '$lib/utils/utils.js';
|
||||||
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
import LoaderCircleIcon from '~icons/lucide/loader-circle';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|
|
||||||
7
src/lib/components/ui/kbd/index.ts
Normal file
7
src/lib/components/ui/kbd/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/*
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Kbd from './kbd.svelte';
|
||||||
|
|
||||||
|
export { Kbd };
|
||||||
53
src/lib/components/ui/kbd/kbd.svelte
Normal file
53
src/lib/components/ui/kbd/kbd.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<!--
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from 'tailwind-variants';
|
||||||
|
|
||||||
|
const style = tv({
|
||||||
|
base: 'inline-flex place-items-center justify-center gap-1 rounded-md p-0.5',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
outline: 'border-border bg-background text-muted-foreground border',
|
||||||
|
secondary: 'bg-secondary text-muted-foreground',
|
||||||
|
primary: 'bg-primary text-primary-foreground',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
|
||||||
|
default: 'min-w-8 gap-1.5 p-1 px-2',
|
||||||
|
lg: 'min-w-9 gap-2 p-1 px-3 text-lg',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Size = VariantProps<typeof style>['size'];
|
||||||
|
type Variant = VariantProps<typeof style>['variant'];
|
||||||
|
|
||||||
|
export type KbdPropsWithoutHTML = {
|
||||||
|
ref?: HTMLElement | null;
|
||||||
|
class?: string;
|
||||||
|
size?: Size;
|
||||||
|
variant?: Variant;
|
||||||
|
children?: Snippet<[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KbdProps = KbdPropsWithoutHTML;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
size = 'sm',
|
||||||
|
variant = 'outline',
|
||||||
|
children,
|
||||||
|
}: KbdProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<kbd bind:this={ref} class={cn(style({ size, variant }), className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</kbd>
|
||||||
3
src/lib/components/ui/label/index.ts
Normal file
3
src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Label from './label.svelte';
|
||||||
|
|
||||||
|
export { Label };
|
||||||
16
src/lib/components/ui/label/label.svelte
Normal file
16
src/lib/components/ui/label/label.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils/utils.js';
|
||||||
|
import type { HTMLLabelAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let { class: className, children, ...rest }: HTMLLabelAttributes = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</label>
|
||||||
1
src/lib/components/ui/search/index.ts
Normal file
1
src/lib/components/ui/search/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Search } from './search.svelte';
|
||||||
13
src/lib/components/ui/search/search.svelte
Normal file
13
src/lib/components/ui/search/search.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
import Search from '~icons/lucide/search';
|
||||||
|
|
||||||
|
let { value = $bindable(''), ...rest }: HTMLInputAttributes = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-input focus-within:ring-ring ring-offset-background relative flex h-9 items-center rounded-md border p-2 text-base ring-offset-2 focus-within:ring-2 md:text-sm"
|
||||||
|
>
|
||||||
|
<Search class="text-muted-foreground size-4" />
|
||||||
|
<input {...rest} bind:value type="text" class="flex-1 bg-transparent px-2 outline-none" />
|
||||||
|
</div>
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
import { cn } from '$lib/utils/utils';
|
import { cn } from '$lib/utils/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { useSidebar } from './sidebar.svelte.js';
|
import { useSidebar } from './sidebar.svelte.js';
|
||||||
|
import { shortcut } from '$lib/actions/shortcut.svelte.js';
|
||||||
|
|
||||||
let { children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
let { children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window use:shortcut={{ key: 'b', ctrl: true, callback: sidebar.toggle }} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
|
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
|
||||||
|
|
|
||||||
3
src/lib/components/ui/textarea/index.ts
Normal file
3
src/lib/components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Textarea from './textarea.svelte';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
15
src/lib/components/ui/textarea/textarea.svelte
Normal file
15
src/lib/components/ui/textarea/textarea.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let { value = $bindable(''), class: className, ...rest }: HTMLTextareaAttributes = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
{...rest}
|
||||||
|
bind:value
|
||||||
|
class={cn(
|
||||||
|
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
></textarea>
|
||||||
7
src/lib/hooks/is-mac.svelte.ts
Normal file
7
src/lib/hooks/is-mac.svelte.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** Attempts to determine if a user is on a Mac using `navigator.userAgent`. */
|
||||||
|
export const isMac = navigator.userAgent.includes('Mac');
|
||||||
|
|
||||||
|
/** `⌘` for mac or `Ctrl` for windows */
|
||||||
|
export const cmdOrCtrl = isMac ? '⌘' : 'Ctrl';
|
||||||
|
/** `⌥` for mac or `Alt` for windows */
|
||||||
|
export const optionOrAlt = isMac ? '⌥' : 'Alt';
|
||||||
|
|
@ -13,7 +13,7 @@ export class Models {
|
||||||
|
|
||||||
init = createInit(() => {
|
init = createInit(() => {
|
||||||
const query = useCachedQuery(api.user_enabled_models.get_enabled, {
|
const query = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||||
user_id: session.current?.user.id ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
});
|
});
|
||||||
watch(
|
watch(
|
||||||
() => $state.snapshot(query.data),
|
() => $state.snapshot(query.data),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
// temporary redirect to /chat
|
// temporary redirect to /chat
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
import { LightSwitch } from '$lib/components/ui/light-switch';
|
import { LightSwitch } from '$lib/components/ui/light-switch';
|
||||||
import ArrowLeftIcon from '~icons/lucide/arrow-left';
|
import ArrowLeftIcon from '~icons/lucide/arrow-left';
|
||||||
import { Avatar } from 'melt/components';
|
import { Avatar } from 'melt/components';
|
||||||
|
import { Kbd } from '$lib/components/ui/kbd/index.js';
|
||||||
|
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@
|
||||||
<Button variant="ghost" onClickPromise={signOut}>Sign out</Button>
|
<Button variant="ghost" onClickPromise={signOut}>Sign out</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="px-4 md:grid md:grid-cols-[280px_1fr]">
|
<div class="px-4 md:grid md:grid-cols-[255px_1fr]">
|
||||||
<div class="hidden md:col-start-1 md:block">
|
<div class="hidden md:col-start-1 md:block">
|
||||||
<div class="flex flex-col place-items-center gap-2">
|
<div class="flex flex-col place-items-center gap-2">
|
||||||
<Avatar src={data.session.user.image ?? undefined}>
|
<Avatar src={data.session.user.image ?? undefined}>
|
||||||
|
|
@ -64,17 +66,30 @@
|
||||||
<p class="text-center text-2xl font-bold">{data.session.user.name}</p>
|
<p class="text-center text-2xl font-bold">{data.session.user.name}</p>
|
||||||
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
|
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 flex w-full flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium">Keyboard Shortcuts</span>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex place-items-center justify-between">
|
||||||
|
<span class="text-muted-foreground text-sm">Toggle Sidebar </span>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Kbd>{cmdOrCtrl}</Kbd>
|
||||||
|
<Kbd>B</Kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-12 md:col-start-2">
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-start-2 md:pl-12">
|
||||||
<div
|
<div
|
||||||
class="bg-card text-muted-foreground flex w-fit place-items-center gap-2 rounded-lg p-1 text-sm"
|
class="bg-card scrollbar-hide text-muted-foreground flex w-fit max-w-full place-items-center gap-2 overflow-x-auto rounded-lg p-1 text-sm"
|
||||||
>
|
>
|
||||||
{#each navigation as tab (tab)}
|
{#each navigation as tab (tab)}
|
||||||
<a
|
<a
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
use:active={{ activeForSubdirectories: false }}
|
use:active={{ activeForSubdirectories: false }}
|
||||||
class="data-[active=true]:bg-background data-[active=true]:text-foreground rounded-md px-2 py-1"
|
class="data-[active=true]:bg-background data-[active=true]:text-foreground rounded-md px-2 py-1 text-nowrap"
|
||||||
>
|
>
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>
|
<Card.Title id={provider}>
|
||||||
<KeyIcon class="inline size-4" />
|
<KeyIcon class="inline size-4" />
|
||||||
{meta.title}
|
{meta.title}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
|
|
|
||||||
121
src/routes/account/customization/+page.svelte
Normal file
121
src/routes/account/customization/+page.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import PlusIcon from '~icons/lucide/plus';
|
||||||
|
import { Collapsible } from 'melt/builders';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
import { useCachedQuery, type QueryResult } from '$lib/cache/cached-query.svelte';
|
||||||
|
import type { Doc } from '$lib/backend/convex/_generated/dataModel';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import Rule from './rule.svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const newRuleCollapsible = new Collapsible({
|
||||||
|
open: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let creatingRule = $state(false);
|
||||||
|
|
||||||
|
const userRulesQuery: QueryResult<Doc<'user_rules'>[]> = useCachedQuery(api.user_rules.all, {
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitNewRule(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const attach = formData.get('attach') as 'always' | 'manual';
|
||||||
|
const rule = formData.get('rule') as string;
|
||||||
|
|
||||||
|
if (rule === '' || !rule) return;
|
||||||
|
|
||||||
|
// cannot create rule with the same name
|
||||||
|
if (userRulesQuery.data?.findIndex((r) => r.name === name) !== -1) return;
|
||||||
|
|
||||||
|
creatingRule = true;
|
||||||
|
|
||||||
|
await client.mutation(api.user_rules.create, {
|
||||||
|
name,
|
||||||
|
attach,
|
||||||
|
rule,
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
newRuleCollapsible.open = false;
|
||||||
|
|
||||||
|
creatingRule = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Customization | Thom.chat</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold">Customization</h1>
|
||||||
|
<h2 class="text-muted-foreground mt-2 text-sm">Customize your experience with Thom.chat.</h2>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col gap-4">
|
||||||
|
<div class="flex place-items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold">Rules</h3>
|
||||||
|
<Button
|
||||||
|
{...newRuleCollapsible.trigger}
|
||||||
|
variant={newRuleCollapsible.open ? 'outline' : 'default'}
|
||||||
|
>
|
||||||
|
{#if newRuleCollapsible.open}
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
{newRuleCollapsible.open ? 'Cancel' : 'New Rule'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if newRuleCollapsible.open}
|
||||||
|
<div
|
||||||
|
{...newRuleCollapsible.content}
|
||||||
|
in:slide={{ duration: 150, axis: 'y' }}
|
||||||
|
out:slide={{ duration: 150, axis: 'y' }}
|
||||||
|
class="bg-card flex flex-col gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-lg font-bold">New Rule</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Create a new rule to customize the behavior of your AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onsubmit={submitNewRule} class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="name">Name (Used when referencing the rule)</Label>
|
||||||
|
<Input id="name" name="name" placeholder="My Rule" required />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="attach">Rule Type</Label>
|
||||||
|
<select
|
||||||
|
id="attach"
|
||||||
|
name="attach"
|
||||||
|
class="border-input bg-background h-9 w-fit rounded-md border px-2 pr-6 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="always">Always</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="rule">Instructions</Label>
|
||||||
|
<Textarea id="rule" name="rule" placeholder="How should the AI respond?" required />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button loading={creatingRule} type="submit">Create Rule</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each userRulesQuery.data ?? [] as rule (rule._id)}
|
||||||
|
<Rule {rule} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
114
src/routes/account/customization/rule.svelte
Normal file
114
src/routes/account/customization/rule.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import type { Doc } from '$lib/backend/convex/_generated/dataModel';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
|
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
|
||||||
|
import { ResultAsync } from 'neverthrow';
|
||||||
|
import TrashIcon from '~icons/lucide/trash';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rule: Doc<'user_rules'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = $props.id();
|
||||||
|
|
||||||
|
let { rule }: Props = $props();
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
let updating = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
const toasts = new LocalToasts({ id });
|
||||||
|
|
||||||
|
async function updateRule(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
|
const attach = formData.get('attach') as 'always' | 'manual';
|
||||||
|
const ruleText = formData.get('rule') as string;
|
||||||
|
|
||||||
|
if (ruleText === '' || !ruleText) return;
|
||||||
|
|
||||||
|
updating = true;
|
||||||
|
|
||||||
|
const res = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.user_rules.update, {
|
||||||
|
ruleId: rule._id,
|
||||||
|
attach,
|
||||||
|
rule: ruleText,
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
}),
|
||||||
|
(e) => e
|
||||||
|
);
|
||||||
|
|
||||||
|
toasts.addToast({
|
||||||
|
data: {
|
||||||
|
content: res.isOk() ? 'Saved' : 'Failed to save',
|
||||||
|
variant: res.isOk() ? 'info' : 'danger',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRule() {
|
||||||
|
deleting = true;
|
||||||
|
|
||||||
|
await client.mutation(api.user_rules.remove, {
|
||||||
|
ruleId: rule._id,
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Card.Title>{rule.name}</Card.Title>
|
||||||
|
<Button variant="destructive" size="icon" onclick={deleteRule} disabled={deleting}>
|
||||||
|
<TrashIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content tag="form" onsubmit={updateRule}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="attach">Rule Type</Label>
|
||||||
|
<select
|
||||||
|
id="attach"
|
||||||
|
name="attach"
|
||||||
|
value={rule.attach}
|
||||||
|
class="border-input bg-background h-9 w-fit rounded-md border px-2 pr-6 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="always">Always</option>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="rule">Instructions</Label>
|
||||||
|
<Textarea
|
||||||
|
id="rule"
|
||||||
|
value={rule.rule}
|
||||||
|
name="rule"
|
||||||
|
placeholder="How should the AI respond?"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button loading={updating} {...toasts.trigger} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{#each toasts.toasts as toast (toast)}
|
||||||
|
<div {...toast.attrs} class={toast.class}>
|
||||||
|
{toast.data.content}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
@ -1,7 +1,42 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { models } from '$lib/state/models.svelte';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Search } from '$lib/components/ui/search';
|
||||||
|
import { session } from '$lib/state/session.svelte';
|
||||||
import { Provider } from '$lib/types.js';
|
import { Provider } from '$lib/types.js';
|
||||||
|
import { cn } from '$lib/utils/utils';
|
||||||
import ModelCard from './model-card.svelte';
|
import ModelCard from './model-card.svelte';
|
||||||
|
import { Toggle } from 'melt/builders';
|
||||||
|
import XIcon from '~icons/lucide/x';
|
||||||
|
import PlusIcon from '~icons/lucide/plus';
|
||||||
|
import { models } from '$lib/state/models.svelte';
|
||||||
|
|
||||||
|
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
|
provider: Provider.OpenRouter,
|
||||||
|
session_token: session.current?.session.token ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOpenRouterKey = $derived(
|
||||||
|
openRouterKeyQuery.data !== undefined && openRouterKeyQuery.data !== ''
|
||||||
|
);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
const openRouterToggle = new Toggle({
|
||||||
|
value: true,
|
||||||
|
// TODO: enable this if and when when we use multiple providers
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openRouterModels = $derived(
|
||||||
|
models.from(Provider.OpenRouter).filter((model) => {
|
||||||
|
if (search !== '' && !hasOpenRouterKey) return false;
|
||||||
|
if (!openRouterToggle.value) return false;
|
||||||
|
|
||||||
|
return model.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -13,8 +48,49 @@
|
||||||
Choose which models appear in your model selector. This won't affect existing conversations.
|
Choose which models appear in your model selector. This won't affect existing conversations.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-col gap-4">
|
<div class="mt-4 flex flex-col gap-2">
|
||||||
{#each models.from(Provider.OpenRouter) as model (model.id)}
|
<Search bind:value={search} placeholder="Search models" />
|
||||||
<ModelCard provider={Provider.OpenRouter} {model} enabled={model.enabled} />
|
<div class="flex place-items-center gap-2">
|
||||||
|
<button
|
||||||
|
{...openRouterToggle.trigger}
|
||||||
|
aria-label="OpenRouter"
|
||||||
|
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
OpenRouter
|
||||||
|
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
|
||||||
|
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if openRouterModels.length > 0}
|
||||||
|
<div class="mt-4 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">OpenRouter</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">Easy access to over 400 models.</p>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class={cn('flex flex-col gap-4 overflow-hidden', {
|
||||||
|
'pointer-events-none max-h-96 mask-b-from-0% mask-b-to-80%': !hasOpenRouterKey,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{#each openRouterModels as model (model.id)}
|
||||||
|
<ModelCard
|
||||||
|
provider={Provider.OpenRouter}
|
||||||
|
{model}
|
||||||
|
enabled={model.enabled}
|
||||||
|
disabled={!hasOpenRouterKey}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !hasOpenRouterKey}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
model: Model;
|
model: Model;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { provider, model, enabled = false }: Props = $props();
|
let { provider, model, enabled = false, disabled = false }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
|
@ -38,13 +39,11 @@
|
||||||
|
|
||||||
async function toggleEnabled(v: boolean) {
|
async function toggleEnabled(v: boolean) {
|
||||||
enabled = v; // Optimistic!
|
enabled = v; // Optimistic!
|
||||||
console.log('hi');
|
|
||||||
if (!session.current?.user.id) return;
|
if (!session.current?.user.id) return;
|
||||||
|
|
||||||
const res = await ResultAsync.fromPromise(
|
const res = await ResultAsync.fromPromise(
|
||||||
client.mutation(api.user_enabled_models.set, {
|
client.mutation(api.user_enabled_models.set, {
|
||||||
provider,
|
provider,
|
||||||
user_id: session.current.user.id,
|
|
||||||
model_id: model.id,
|
model_id: model.id,
|
||||||
enabled: v,
|
enabled: v,
|
||||||
session_token: session.current?.session.token,
|
session_token: session.current?.session.token,
|
||||||
|
|
@ -60,8 +59,7 @@
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Card.Title>{model.name}</Card.Title>
|
<Card.Title>{model.name}</Card.Title>
|
||||||
<!-- TODO: make this actually work -->
|
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
|
||||||
<Switch bind:value={() => enabled, toggleEnabled} />
|
|
||||||
</div>
|
</div>
|
||||||
<Card.Description
|
<Card.Description
|
||||||
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
|
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
|
||||||
|
|
@ -71,6 +69,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground w-fit text-start text-xs"
|
class="text-muted-foreground w-fit text-start text-xs"
|
||||||
onclick={() => (showMore = !showMore)}
|
onclick={() => (showMore = !showMore)}
|
||||||
|
{disabled}
|
||||||
>
|
>
|
||||||
{showMore ? 'Show less' : 'Show more'}
|
{showMore ? 'Show less' : 'Show more'}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
import type { Id } from '$lib/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
import type { SessionObj } from '$lib/backend/convex/betterAuth';
|
import type { SessionObj } from '$lib/backend/convex/betterAuth';
|
||||||
import { Provider } from '$lib/types';
|
import { Provider } from '$lib/types';
|
||||||
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import { ConvexHttpClient } from 'convex/browser';
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
import { ResultAsync } from 'neverthrow';
|
import { ResultAsync } from 'neverthrow';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
|
import { waitUntil } from '@vercel/functions';
|
||||||
|
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
|
|
@ -40,22 +41,32 @@ function log(message: string, startTime: number): void {
|
||||||
|
|
||||||
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
async function generateAIResponse(
|
async function generateAIResponse({
|
||||||
conversationId: string,
|
conversationId,
|
||||||
session: SessionObj,
|
session,
|
||||||
modelId: string,
|
startTime,
|
||||||
startTime: number
|
modelResultPromise,
|
||||||
) {
|
keyResultPromise,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
session: SessionObj;
|
||||||
|
startTime: number;
|
||||||
|
keyResultPromise: ResultAsync<string | null, string>;
|
||||||
|
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
|
||||||
|
}) {
|
||||||
log('Starting AI response generation in background', startTime);
|
log('Starting AI response generation in background', startTime);
|
||||||
|
|
||||||
const modelResult = await ResultAsync.fromPromise(
|
const [modelResult, keyResult, messagesQueryResult] = await Promise.all([
|
||||||
client.query(api.user_enabled_models.get, {
|
modelResultPromise,
|
||||||
provider: Provider.OpenRouter,
|
keyResultPromise,
|
||||||
model_id: modelId,
|
ResultAsync.fromPromise(
|
||||||
user_id: session.userId,
|
client.query(api.messages.getAllFromConversation, {
|
||||||
|
conversation_id: conversationId as Id<'conversations'>,
|
||||||
|
session_token: session.token,
|
||||||
}),
|
}),
|
||||||
(e) => `Failed to get model: ${e}`
|
(e) => `Failed to get messages: ${e}`
|
||||||
);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (modelResult.isErr()) {
|
if (modelResult.isErr()) {
|
||||||
log(`Background model query failed: ${modelResult.error}`, startTime);
|
log(`Background model query failed: ${modelResult.error}`, startTime);
|
||||||
|
|
@ -70,13 +81,7 @@ async function generateAIResponse(
|
||||||
|
|
||||||
log('Background: Model found and enabled', startTime);
|
log('Background: Model found and enabled', startTime);
|
||||||
|
|
||||||
const messagesQuery = await ResultAsync.fromPromise(
|
const messagesQuery = await messagesQueryResult;
|
||||||
client.query(api.messages.getAllFromConversation, {
|
|
||||||
conversation_id: conversationId as Id<'conversations'>,
|
|
||||||
session_token: session.token,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to get messages: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (messagesQuery.isErr()) {
|
if (messagesQuery.isErr()) {
|
||||||
log(`Background messages query failed: ${messagesQuery.error}`, startTime);
|
log(`Background messages query failed: ${messagesQuery.error}`, startTime);
|
||||||
|
|
@ -86,14 +91,6 @@ async function generateAIResponse(
|
||||||
const messages = messagesQuery.value;
|
const messages = messagesQuery.value;
|
||||||
log(`Background: Retrieved ${messages.length} messages from conversation`, startTime);
|
log(`Background: Retrieved ${messages.length} messages from conversation`, startTime);
|
||||||
|
|
||||||
const keyResult = await ResultAsync.fromPromise(
|
|
||||||
client.query(api.user_keys.get, {
|
|
||||||
provider: Provider.OpenRouter,
|
|
||||||
session_token: session.token,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to get API key: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (keyResult.isErr()) {
|
if (keyResult.isErr()) {
|
||||||
log(`Background API key query failed: ${keyResult.error}`, startTime);
|
log(`Background API key query failed: ${keyResult.error}`, startTime);
|
||||||
return;
|
return;
|
||||||
|
|
@ -228,6 +225,23 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
return error(401, 'Unauthorized');
|
return error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelResultPromise = ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_enabled_models.get, {
|
||||||
|
provider: Provider.OpenRouter,
|
||||||
|
model_id: args.model_id,
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get model: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyResultPromise = ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_keys.get, {
|
||||||
|
provider: Provider.OpenRouter,
|
||||||
|
session_token: session.token,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get API key: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
log('Session authenticated successfully', startTime);
|
log('Session authenticated successfully', startTime);
|
||||||
|
|
||||||
let conversationId = args.conversation_id;
|
let conversationId = args.conversation_id;
|
||||||
|
|
@ -271,10 +285,31 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start AI response generation in background - don't await
|
// Start AI response generation in background - don't await
|
||||||
generateAIResponse(conversationId, session, args.model_id, startTime).catch((error) => {
|
waitUntil(
|
||||||
|
generateAIResponse({
|
||||||
|
conversationId,
|
||||||
|
session,
|
||||||
|
startTime,
|
||||||
|
modelResultPromise,
|
||||||
|
keyResultPromise,
|
||||||
|
}).catch((error) => {
|
||||||
log(`Background AI response generation error: ${error}`, startTime);
|
log(`Background AI response generation error: ${error}`, startTime);
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
log('Response sent, AI generation started in background', startTime);
|
log('Response sent, AI generation started in background', startTime);
|
||||||
return response({ ok: true, conversation_id: conversationId });
|
return response({ ok: true, conversation_id: conversationId });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<'user_rules'>[] {
|
||||||
|
// const matchedRules: Doc<'user_rules'>[] = [];
|
||||||
|
|
||||||
|
// for (const rule of rules) {
|
||||||
|
// const match = message.indexOf(`@${rule.name} `);
|
||||||
|
// if (match === -1) continue;
|
||||||
|
|
||||||
|
// matchedRules.push(rule);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return matchedRules;
|
||||||
|
// }
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
<Sidebar.Trigger class="fixed top-3 left-2">
|
<Sidebar.Trigger class="fixed top-3 left-2">
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
</Sidebar.Trigger>
|
</Sidebar.Trigger>
|
||||||
<div class="mx-auto flex size-full max-w-3xl flex-col">
|
<div class="mx-auto flex size-full min-h-svh max-w-3xl flex-col">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
<div class="mt-auto flex w-full flex-col gap-1">
|
<div class="mt-auto flex w-full flex-col gap-1">
|
||||||
<ModelPicker class=" w-min " />
|
<ModelPicker class=" w-min " />
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div class="flex w-full place-items-center justify-between gap-2">
|
<div class="flex w-full place-items-center justify-between gap-2 pb-1">
|
||||||
<span class="text-muted-foreground text-xs">
|
<span class="text-muted-foreground text-xs">
|
||||||
Crafted by <Icons.Svelte class="inline size-3" /> wizards.
|
Crafted by <Icons.Svelte class="inline size-3" /> wizards.
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
|
|
||||||
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
|
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
|
||||||
user_id: session.current?.user.id ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"types": [
|
"types": ["unplugin-icons/types/svelte"]
|
||||||
"unplugin-icons/types/svelte"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue