Merge pull request #7 from TGlide/model-list

Model list searching
This commit is contained in:
Thomas G. Lopes 2025-06-16 22:12:48 +01:00 committed by GitHub
commit 89c6fea1e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1188 additions and 391 deletions

View file

@ -4,3 +4,6 @@ pnpm-lock.yaml
yarn.lock yarn.lock
bun.lock bun.lock
bun.lockb bun.lockb
# Convex formats this
convex.json

View file

@ -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'],

View file

@ -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
View file

@ -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: {}

View file

@ -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 */
}

View 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';

View file

@ -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: [],
}); });

View file

@ -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: {

View file

@ -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(),

View file

@ -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,
}); });
} }

View 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;
},
});

View file

@ -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;

View file

@ -1,136 +1,136 @@
interface CacheNode<K, V> { interface CacheNode<K, V> {
key: K; key: K;
value: V; value: V;
size: number; size: number;
prev: CacheNode<K, V> | null; prev: CacheNode<K, V> | null;
next: CacheNode<K, V> | null; next: CacheNode<K, V> | null;
} }
export class LRUCache<K = string, V = unknown> { export class LRUCache<K = string, V = unknown> {
private capacity: number; private capacity: number;
private currentSize = 0; private currentSize = 0;
private cache = new Map<K, CacheNode<K, V>>(); private cache = new Map<K, CacheNode<K, V>>();
private head: CacheNode<K, V> | null = null; private head: CacheNode<K, V> | null = null;
private tail: CacheNode<K, V> | null = null; private tail: CacheNode<K, V> | null = null;
constructor(maxSizeBytes = 1024 * 1024) { constructor(maxSizeBytes = 1024 * 1024) {
this.capacity = maxSizeBytes; this.capacity = maxSizeBytes;
} }
private calculateSize(value: V): number { private calculateSize(value: V): number {
try { try {
return new Blob([JSON.stringify(value)]).size; return new Blob([JSON.stringify(value)]).size;
} catch { } catch {
return JSON.stringify(value).length * 2; return JSON.stringify(value).length * 2;
} }
} }
private removeNode(node: CacheNode<K, V>): void { private removeNode(node: CacheNode<K, V>): void {
if (node.prev) { if (node.prev) {
node.prev.next = node.next; node.prev.next = node.next;
} else { } else {
this.head = node.next; this.head = node.next;
} }
if (node.next) { if (node.next) {
node.next.prev = node.prev; node.next.prev = node.prev;
} else { } else {
this.tail = node.prev; this.tail = node.prev;
} }
} }
private addToHead(node: CacheNode<K, V>): void { private addToHead(node: CacheNode<K, V>): void {
node.prev = null; node.prev = null;
node.next = this.head; node.next = this.head;
if (this.head) { if (this.head) {
this.head.prev = node; this.head.prev = node;
} }
this.head = node; this.head = node;
if (!this.tail) { if (!this.tail) {
this.tail = node; this.tail = node;
} }
} }
private evictLRU(): void { private evictLRU(): void {
while (this.tail && this.currentSize > this.capacity) { while (this.tail && this.currentSize > this.capacity) {
const lastNode = this.tail; const lastNode = this.tail;
this.removeNode(lastNode); this.removeNode(lastNode);
this.cache.delete(lastNode.key); this.cache.delete(lastNode.key);
this.currentSize -= lastNode.size; this.currentSize -= lastNode.size;
} }
} }
get(key: K): V | undefined { get(key: K): V | undefined {
const node = this.cache.get(key); const node = this.cache.get(key);
if (!node) return undefined; if (!node) return undefined;
this.removeNode(node); this.removeNode(node);
this.addToHead(node); this.addToHead(node);
return node.value; return node.value;
} }
set(key: K, value: V): void { set(key: K, value: V): void {
const size = this.calculateSize(value); const size = this.calculateSize(value);
if (size > this.capacity) { if (size > this.capacity) {
return; return;
} }
const existingNode = this.cache.get(key); const existingNode = this.cache.get(key);
if (existingNode) { if (existingNode) {
existingNode.value = value; existingNode.value = value;
this.currentSize = this.currentSize - existingNode.size + size; this.currentSize = this.currentSize - existingNode.size + size;
existingNode.size = size; existingNode.size = size;
this.removeNode(existingNode); this.removeNode(existingNode);
this.addToHead(existingNode); this.addToHead(existingNode);
} else { } else {
const newNode: CacheNode<K, V> = { const newNode: CacheNode<K, V> = {
key, key,
value, value,
size, size,
prev: null, prev: null,
next: null, next: null,
}; };
this.currentSize += size; this.currentSize += size;
this.cache.set(key, newNode); this.cache.set(key, newNode);
this.addToHead(newNode); this.addToHead(newNode);
} }
this.evictLRU(); this.evictLRU();
} }
delete(key: K): boolean { delete(key: K): boolean {
const node = this.cache.get(key); const node = this.cache.get(key);
if (!node) return false; if (!node) return false;
this.removeNode(node); this.removeNode(node);
this.cache.delete(key); this.cache.delete(key);
this.currentSize -= node.size; this.currentSize -= node.size;
return true; return true;
} }
clear(): void { clear(): void {
this.cache.clear(); this.cache.clear();
this.head = null; this.head = null;
this.tail = null; this.tail = null;
this.currentSize = 0; this.currentSize = 0;
} }
get size(): number { get size(): number {
return this.cache.size; return this.cache.size;
} }
get bytes(): number { get bytes(): number {
return this.currentSize; return this.currentSize;
} }
has(key: K): boolean { has(key: K): boolean {
return this.cache.has(key); return this.cache.has(key);
} }
} }

View file

@ -1,160 +1,156 @@
import { LRUCache } from './lru-cache.js'; import { LRUCache } from './lru-cache.js';
interface CacheEntry<T> { interface CacheEntry<T> {
data: T; data: T;
timestamp: number; timestamp: number;
ttl?: number; ttl?: number;
} }
export class SessionStorageCache<T = unknown> { export class SessionStorageCache<T = unknown> {
private memoryCache: LRUCache<string, CacheEntry<T>>; private memoryCache: LRUCache<string, CacheEntry<T>>;
private storageKey: string; private storageKey: string;
private writeTimeout: ReturnType<typeof setTimeout> | null = null; private writeTimeout: ReturnType<typeof setTimeout> | null = null;
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', this.storageKey = storageKey;
maxSizeBytes = 1024 * 1024, this.debounceMs = debounceMs;
debounceMs = 300 this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
) { this.loadFromSessionStorage();
this.storageKey = storageKey; }
this.debounceMs = debounceMs;
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);
this.loadFromSessionStorage();
}
private loadFromSessionStorage(): void { private loadFromSessionStorage(): void {
try { try {
const stored = sessionStorage.getItem(this.storageKey); const stored = sessionStorage.getItem(this.storageKey);
if (!stored) return; if (!stored) return;
const data = JSON.parse(stored) as Record<string, CacheEntry<T>>; const data = JSON.parse(stored) as Record<string, CacheEntry<T>>;
const now = Date.now(); const now = Date.now();
for (const [key, entry] of Object.entries(data)) { for (const [key, entry] of Object.entries(data)) {
if (entry.ttl && now - entry.timestamp > entry.ttl) { if (entry.ttl && now - entry.timestamp > entry.ttl) {
continue; continue;
} }
this.memoryCache.set(key, entry); this.memoryCache.set(key, entry);
} }
} catch (error) { } catch (error) {
console.warn('Failed to load cache from sessionStorage:', error); console.warn('Failed to load cache from sessionStorage:', error);
} }
} }
private debouncedWrite(): void { private debouncedWrite(): void {
if (this.writeTimeout) { if (this.writeTimeout) {
clearTimeout(this.writeTimeout); clearTimeout(this.writeTimeout);
} }
this.writeTimeout = setTimeout(() => { this.writeTimeout = setTimeout(() => {
this.writeToSessionStorage(); this.writeToSessionStorage();
this.writeTimeout = null; this.writeTimeout = null;
}, this.debounceMs); }, this.debounceMs);
} }
private writeToSessionStorage(): void { private writeToSessionStorage(): void {
try { try {
const cacheData: Record<string, CacheEntry<T>> = {}; const cacheData: Record<string, CacheEntry<T>> = {};
const now = Date.now(); const now = Date.now();
for (const key of this.pendingWrites) { for (const key of this.pendingWrites) {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) { if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry; cacheData[key] = entry;
} }
} }
const existingData = sessionStorage.getItem(this.storageKey); const existingData = sessionStorage.getItem(this.storageKey);
if (existingData) { if (existingData) {
const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>; const existing = JSON.parse(existingData) as Record<string, CacheEntry<T>>;
for (const [key, entry] of Object.entries(existing)) { for (const [key, entry] of Object.entries(existing)) {
if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) { if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) {
cacheData[key] = entry; cacheData[key] = entry;
} }
} }
} }
sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData)); sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData));
this.pendingWrites.clear(); this.pendingWrites.clear();
} catch (error) { } catch (error) {
console.warn('Failed to write cache to sessionStorage:', error); console.warn('Failed to write cache to sessionStorage:', error);
} }
} }
get(key: string): T | undefined { get(key: string): T | undefined {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (!entry) return undefined; if (!entry) return undefined;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key); this.delete(key);
return undefined; return undefined;
} }
return entry.data; return entry.data;
} }
set(key: string, data: T, ttlMs?: number): void { set(key: string, data: T, ttlMs?: number): void {
const entry: CacheEntry<T> = { const entry: CacheEntry<T> = {
data, data,
timestamp: Date.now(), timestamp: Date.now(),
ttl: ttlMs, ttl: ttlMs,
}; };
this.memoryCache.set(key, entry); this.memoryCache.set(key, entry);
this.pendingWrites.add(key); this.pendingWrites.add(key);
this.debouncedWrite(); this.debouncedWrite();
} }
delete(key: string): boolean { delete(key: string): boolean {
const deleted = this.memoryCache.delete(key); const deleted = this.memoryCache.delete(key);
if (deleted) { if (deleted) {
this.pendingWrites.add(key); this.pendingWrites.add(key);
this.debouncedWrite(); this.debouncedWrite();
} }
return deleted; return deleted;
} }
clear(): void { clear(): void {
this.memoryCache.clear(); this.memoryCache.clear();
try { try {
sessionStorage.removeItem(this.storageKey); sessionStorage.removeItem(this.storageKey);
} catch (error) { } catch (error) {
console.warn('Failed to clear sessionStorage:', error); console.warn('Failed to clear sessionStorage:', error);
} }
if (this.writeTimeout) { if (this.writeTimeout) {
clearTimeout(this.writeTimeout); clearTimeout(this.writeTimeout);
this.writeTimeout = null; this.writeTimeout = null;
} }
this.pendingWrites.clear(); this.pendingWrites.clear();
} }
has(key: string): boolean { has(key: string): boolean {
const entry = this.memoryCache.get(key); const entry = this.memoryCache.get(key);
if (!entry) return false; if (!entry) return false;
if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) {
this.delete(key); this.delete(key);
return false; return false;
} }
return true; return true;
} }
get size(): number { get size(): number {
return this.memoryCache.size; return this.memoryCache.size;
} }
get bytes(): number { get bytes(): number {
return this.memoryCache.bytes; return this.memoryCache.bytes;
} }
forceWrite(): void { forceWrite(): void {
if (this.writeTimeout) { if (this.writeTimeout) {
clearTimeout(this.writeTimeout); clearTimeout(this.writeTimeout);
this.writeTimeout = null; this.writeTimeout = null;
} }
this.writeToSessionStorage(); this.writeToSessionStorage();
} }
} }

View file

@ -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),

View file

@ -0,0 +1,7 @@
/*
Installed from @ieedan/shadcn-svelte-extras
*/
import Kbd from './kbd.svelte';
export { Kbd };

View 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>

View file

@ -0,0 +1,3 @@
import Label from './label.svelte';
export { Label };

View 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>

View file

@ -0,0 +1 @@
export { default as Search } from './search.svelte';

View 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>

View file

@ -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]', {

View file

@ -0,0 +1,3 @@
import Textarea from './textarea.svelte';
export { Textarea };

View 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>

View 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';

View file

@ -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),

View file

@ -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

View file

@ -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> </div>
</div> </div>
<div class="pl-12 md:col-start-2"> <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>

View file

@ -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>

View 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>

View 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}

View file

@ -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">
{/each} <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> </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}
</div>
{#if !hasOpenRouterKey}
<div
class="absolute bottom-10 left-0 z-10 flex w-full place-items-center justify-center gap-2"
>
<Button href="/account/api-keys#openrouter" class="w-fit">Add API Key</Button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -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>

View file

@ -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'>,
(e) => `Failed to get model: ${e}` session_token: session.token,
); }),
(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(
log(`Background AI response generation error: ${error}`, startTime); generateAIResponse({
}); conversationId,
session,
startTime,
modelResultPromise,
keyResultPromise,
}).catch((error) => {
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;
// }

View file

@ -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>

View file

@ -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 ?? {}));

View file

@ -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