diff --git a/.prettierignore b/.prettierignore index 6562bcb..53da76a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ pnpm-lock.yaml yarn.lock bun.lock bun.lockb + +# Convex formats this +convex.json \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 3e0ca46..4bd124f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,7 +20,16 @@ export default ts.config( languageOptions: { 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'], diff --git a/package.json b/package.json index 4da316a..a480f6e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/svelte": "^5.2.4", - "bits-ui": "^2.6.2", + "@vercel/functions": "^2.2.0", "clsx": "^2.1.1", "concurrently": "^9.1.2", "convex": "^1.24.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4871b90..d0429b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,9 @@ importers: '@testing-library/svelte': 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)) - bits-ui: - specifier: ^2.6.2 - version: 2.6.2(@internationalized/date@3.8.2)(svelte@5.34.1) + '@vercel/functions': + specifier: ^2.2.0 + version: 2.2.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -620,9 +620,6 @@ packages: '@iconify/utils@2.3.0': resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} - '@internationalized/date@3.8.2': - resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -838,9 +835,6 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 - '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - '@tailwindcss/node@4.1.10': resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==} @@ -1035,6 +1029,15 @@ packages: resolution: {integrity: sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==} 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': resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==} @@ -1128,13 +1131,6 @@ packages: better-call@1.0.9: 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: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2180,12 +2176,6 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==} engines: {node: '>=18'} @@ -2193,9 +2183,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tabbable@6.2.0: - resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} - tailwind-merge@3.0.2: resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==} @@ -2831,10 +2818,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@internationalized/date@3.8.2': - dependencies: - '@swc/helpers': 0.5.17 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -3040,10 +3023,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc/helpers@0.5.17': - dependencies: - tslib: 2.8.1 - '@tailwindcss/node@4.1.10': dependencies: '@ampproject/remapping': 2.3.0 @@ -3257,6 +3236,8 @@ snapshots: '@typescript-eslint/types': 8.34.0 eslint-visitor-keys: 4.2.1 + '@vercel/functions@2.2.0': {} + '@vitest/expect@3.2.3': dependencies: '@types/chai': 5.2.2 @@ -3366,18 +3347,6 @@ snapshots: set-cookie-parser: 2.7.1 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: dependencies: balanced-match: 1.0.2 @@ -4341,13 +4310,6 @@ snapshots: style-to-object: 1.0.9 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: dependencies: '@ampproject/remapping': 2.3.0 @@ -4367,8 +4329,6 @@ snapshots: symbol-tree@3.2.4: {} - tabbable@6.2.0: {} - tailwind-merge@3.0.2: {} tailwind-merge@3.3.1: {} diff --git a/src/app.css b/src/app.css index 70e026c..1304726 100644 --- a/src/app.css +++ b/src/app.css @@ -3,7 +3,7 @@ @import '@fontsource-variable/geist-mono'; @import '@fontsource-variable/fraunces'; -@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant dark (&:is(.dark *)); :root { --background: oklch(0.9754 0.0084 325.6414); @@ -72,7 +72,7 @@ --muted-foreground: oklch(0.794 0.0372 307.1032); --accent: oklch(0.3649 0.0508 308.4911); --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); --border: oklch(0.3286 0.0154 343.4461); --input: oklch(0.3387 0.0195 332.8347); @@ -229,3 +229,13 @@ @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 */ +} diff --git a/src/lib/actions/shortcut.svelte.ts b/src/lib/actions/shortcut.svelte.ts new file mode 100644 index 0000000..50c06a5 --- /dev/null +++ b/src/lib/actions/shortcut.svelte.ts @@ -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 + * + * + * ``` + */ +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 + * + * + * ``` + */ +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'; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index af34f1e..581cbc0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -14,14 +14,12 @@ export const auth = betterAuth({ clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, }, - // databaseHooks: { - // user: { - // create: { - // after: async ({ user }) => { - // // TODO: automatically enable default models for the user - // }, - // }, - // }, - // }, + databaseHooks: { + user: { + create: { + after: async (_user) => {}, + }, + }, + }, plugins: [], }); diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index c15b58e..2f110a9 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { api } from './_generated/api'; import { messageRoleValidator, providerValidator } from './schema'; -import { Id } from './_generated/dataModel'; +import type { Id } from './_generated/dataModel'; export const getAllFromConversation = query({ args: { diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index 77cb093..29d5bec 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -11,6 +11,8 @@ export const messageRoleValidator = v.union( export type MessageRole = Infer; +export const ruleAttachValidator = v.union(v.literal('always'), v.literal('manual')); + export default defineSchema({ user_keys: defineTable({ user_id: v.string(), @@ -30,6 +32,15 @@ export default defineSchema({ .index('by_model_provider', ['model_id', 'provider']) .index('by_provider_user', ['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({ user_id: v.string(), title: v.string(), diff --git a/src/lib/backend/convex/user_enabled_models.ts b/src/lib/backend/convex/user_enabled_models.ts index d4650b9..d5ca9f9 100644 --- a/src/lib/backend/convex/user_enabled_models.ts +++ b/src/lib/backend/convex/user_enabled_models.ts @@ -5,6 +5,7 @@ import * as array from '../../utils/array'; import * as object from '../../utils/object'; import { internal } from './_generated/api'; import { Provider } from '../../types'; +import type { Doc } from './_generated/dataModel'; export const getModelKey = (args: { provider: Provider; model_id: string }) => { return `${args.provider}:${args.model_id}`; @@ -12,12 +13,18 @@ export const getModelKey = (args: { provider: Provider; model_id: string }) => { export const get_enabled = query({ args: { - user_id: v.string(), + session_token: v.string(), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise>> => { + 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 .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(); return array.toMap(models, (m) => [getModelKey(m), m]); @@ -26,15 +33,21 @@ export const get_enabled = query({ export const is_enabled = query({ args: { - user_id: v.string(), + sessionToken: v.string(), provider: providerValidator, model_id: v.string(), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise => { + const session = await ctx.runQuery(internal.betterAuth.getSession, { + sessionToken: args.sessionToken, + }); + + if (!session) throw new Error('Invalid session token'); + const model = await ctx.db .query('user_enabled_models') .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(); @@ -46,13 +59,19 @@ export const get = query({ args: { provider: providerValidator, model_id: v.string(), - user_id: v.string(), + session_token: v.string(), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise | 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 .query('user_enabled_models') .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(); @@ -64,7 +83,6 @@ export const set = mutation({ args: { provider: providerValidator, model_id: v.string(), - user_id: v.string(), enabled: v.boolean(), session_token: v.string(), }, @@ -73,9 +91,7 @@ export const set = mutation({ sessionToken: args.session_token, }); - if (!session) { - throw new Error('Unauthorized'); - } + if (!session) throw new Error('Invalid session token'); const existing = await ctx.db .query('user_enabled_models') @@ -90,7 +106,8 @@ export const set = mutation({ await ctx.db.delete(existing._id); } else { 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, }); } diff --git a/src/lib/backend/convex/user_rules.ts b/src/lib/backend/convex/user_rules.ts new file mode 100644 index 0000000..503a477 --- /dev/null +++ b/src/lib/backend/convex/user_rules.ts @@ -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[]> => { + 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; + }, +}); diff --git a/src/lib/cache/cached-query.svelte.ts b/src/lib/cache/cached-query.svelte.ts index 959fb8d..7647129 100644 --- a/src/lib/cache/cached-query.svelte.ts +++ b/src/lib/cache/cached-query.svelte.ts @@ -3,14 +3,14 @@ import { SessionStorageCache } from './session-cache.js'; import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server'; import { extract, watch } from 'runed'; -interface CachedQueryOptions { +export interface CachedQueryOptions { cacheKey?: string; ttl?: number; staleWhileRevalidate?: boolean; enabled?: boolean; } -interface QueryResult { +export interface QueryResult { data: T | undefined; error: Error | undefined; isLoading: boolean; diff --git a/src/lib/cache/lru-cache.ts b/src/lib/cache/lru-cache.ts index 18334f7..39ac584 100644 --- a/src/lib/cache/lru-cache.ts +++ b/src/lib/cache/lru-cache.ts @@ -1,136 +1,136 @@ interface CacheNode { - key: K; - value: V; - size: number; - prev: CacheNode | null; - next: CacheNode | null; + key: K; + value: V; + size: number; + prev: CacheNode | null; + next: CacheNode | null; } export class LRUCache { - private capacity: number; - private currentSize = 0; - private cache = new Map>(); - private head: CacheNode | null = null; - private tail: CacheNode | null = null; + private capacity: number; + private currentSize = 0; + private cache = new Map>(); + private head: CacheNode | null = null; + private tail: CacheNode | null = null; - constructor(maxSizeBytes = 1024 * 1024) { - this.capacity = maxSizeBytes; - } + constructor(maxSizeBytes = 1024 * 1024) { + this.capacity = maxSizeBytes; + } - private calculateSize(value: V): number { - try { - return new Blob([JSON.stringify(value)]).size; - } catch { - return JSON.stringify(value).length * 2; - } - } + private calculateSize(value: V): number { + try { + return new Blob([JSON.stringify(value)]).size; + } catch { + return JSON.stringify(value).length * 2; + } + } - private removeNode(node: CacheNode): void { - if (node.prev) { - node.prev.next = node.next; - } else { - this.head = node.next; - } + private removeNode(node: CacheNode): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } - if (node.next) { - node.next.prev = node.prev; - } else { - this.tail = node.prev; - } - } + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + } - private addToHead(node: CacheNode): void { - node.prev = null; - node.next = this.head; + private addToHead(node: CacheNode): void { + node.prev = null; + node.next = this.head; - if (this.head) { - this.head.prev = node; - } + if (this.head) { + this.head.prev = node; + } - this.head = node; + this.head = node; - if (!this.tail) { - this.tail = node; - } - } + if (!this.tail) { + this.tail = node; + } + } - private evictLRU(): void { - while (this.tail && this.currentSize > this.capacity) { - const lastNode = this.tail; - this.removeNode(lastNode); - this.cache.delete(lastNode.key); - this.currentSize -= lastNode.size; - } - } + private evictLRU(): void { + while (this.tail && this.currentSize > this.capacity) { + const lastNode = this.tail; + this.removeNode(lastNode); + this.cache.delete(lastNode.key); + this.currentSize -= lastNode.size; + } + } - get(key: K): V | undefined { - const node = this.cache.get(key); - if (!node) return undefined; + get(key: K): V | undefined { + const node = this.cache.get(key); + if (!node) return undefined; - this.removeNode(node); - this.addToHead(node); + this.removeNode(node); + this.addToHead(node); - return node.value; - } + return node.value; + } - set(key: K, value: V): void { - const size = this.calculateSize(value); - - if (size > this.capacity) { - return; - } + set(key: K, value: V): void { + const size = this.calculateSize(value); - const existingNode = this.cache.get(key); - - if (existingNode) { - existingNode.value = value; - this.currentSize = this.currentSize - existingNode.size + size; - existingNode.size = size; - this.removeNode(existingNode); - this.addToHead(existingNode); - } else { - const newNode: CacheNode = { - key, - value, - size, - prev: null, - next: null, - }; + if (size > this.capacity) { + return; + } - this.currentSize += size; - this.cache.set(key, newNode); - this.addToHead(newNode); - } + const existingNode = this.cache.get(key); - this.evictLRU(); - } + if (existingNode) { + existingNode.value = value; + this.currentSize = this.currentSize - existingNode.size + size; + existingNode.size = size; + this.removeNode(existingNode); + this.addToHead(existingNode); + } else { + const newNode: CacheNode = { + key, + value, + size, + prev: null, + next: null, + }; - delete(key: K): boolean { - const node = this.cache.get(key); - if (!node) return false; + this.currentSize += size; + this.cache.set(key, newNode); + this.addToHead(newNode); + } - this.removeNode(node); - this.cache.delete(key); - this.currentSize -= node.size; - return true; - } + this.evictLRU(); + } - clear(): void { - this.cache.clear(); - this.head = null; - this.tail = null; - this.currentSize = 0; - } + delete(key: K): boolean { + const node = this.cache.get(key); + if (!node) return false; - get size(): number { - return this.cache.size; - } + this.removeNode(node); + this.cache.delete(key); + this.currentSize -= node.size; + return true; + } - get bytes(): number { - return this.currentSize; - } + clear(): void { + this.cache.clear(); + this.head = null; + this.tail = null; + this.currentSize = 0; + } - has(key: K): boolean { - return this.cache.has(key); - } -} \ No newline at end of file + get size(): number { + return this.cache.size; + } + + get bytes(): number { + return this.currentSize; + } + + has(key: K): boolean { + return this.cache.has(key); + } +} diff --git a/src/lib/cache/session-cache.ts b/src/lib/cache/session-cache.ts index 445924f..1abbeab 100644 --- a/src/lib/cache/session-cache.ts +++ b/src/lib/cache/session-cache.ts @@ -1,160 +1,156 @@ import { LRUCache } from './lru-cache.js'; interface CacheEntry { - data: T; - timestamp: number; - ttl?: number; + data: T; + timestamp: number; + ttl?: number; } export class SessionStorageCache { - private memoryCache: LRUCache>; - private storageKey: string; - private writeTimeout: ReturnType | null = null; - private debounceMs: number; - private pendingWrites = new Set(); + private memoryCache: LRUCache>; + private storageKey: string; + private writeTimeout: ReturnType | null = null; + private debounceMs: number; + private pendingWrites = new Set(); - constructor( - storageKey = 'query-cache', - maxSizeBytes = 1024 * 1024, - debounceMs = 300 - ) { - this.storageKey = storageKey; - this.debounceMs = debounceMs; - this.memoryCache = new LRUCache>(maxSizeBytes); - this.loadFromSessionStorage(); - } + constructor(storageKey = 'query-cache', maxSizeBytes = 1024 * 1024, debounceMs = 300) { + this.storageKey = storageKey; + this.debounceMs = debounceMs; + this.memoryCache = new LRUCache>(maxSizeBytes); + this.loadFromSessionStorage(); + } - private loadFromSessionStorage(): void { - try { - const stored = sessionStorage.getItem(this.storageKey); - if (!stored) return; + private loadFromSessionStorage(): void { + try { + const stored = sessionStorage.getItem(this.storageKey); + if (!stored) return; - const data = JSON.parse(stored) as Record>; - const now = Date.now(); + const data = JSON.parse(stored) as Record>; + const now = Date.now(); - for (const [key, entry] of Object.entries(data)) { - if (entry.ttl && now - entry.timestamp > entry.ttl) { - continue; - } - this.memoryCache.set(key, entry); - } - } catch (error) { - console.warn('Failed to load cache from sessionStorage:', error); - } - } + for (const [key, entry] of Object.entries(data)) { + if (entry.ttl && now - entry.timestamp > entry.ttl) { + continue; + } + this.memoryCache.set(key, entry); + } + } catch (error) { + console.warn('Failed to load cache from sessionStorage:', error); + } + } - private debouncedWrite(): void { - if (this.writeTimeout) { - clearTimeout(this.writeTimeout); - } + private debouncedWrite(): void { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + } - this.writeTimeout = setTimeout(() => { - this.writeToSessionStorage(); - this.writeTimeout = null; - }, this.debounceMs); - } + this.writeTimeout = setTimeout(() => { + this.writeToSessionStorage(); + this.writeTimeout = null; + }, this.debounceMs); + } - private writeToSessionStorage(): void { - try { - const cacheData: Record> = {}; - const now = Date.now(); + private writeToSessionStorage(): void { + try { + const cacheData: Record> = {}; + const now = Date.now(); - for (const key of this.pendingWrites) { - const entry = this.memoryCache.get(key); - if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) { - cacheData[key] = entry; - } - } + for (const key of this.pendingWrites) { + const entry = this.memoryCache.get(key); + if (entry && (!entry.ttl || now - entry.timestamp < entry.ttl)) { + cacheData[key] = entry; + } + } - const existingData = sessionStorage.getItem(this.storageKey); - if (existingData) { - const existing = JSON.parse(existingData) as Record>; - for (const [key, entry] of Object.entries(existing)) { - if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) { - cacheData[key] = entry; - } - } - } + const existingData = sessionStorage.getItem(this.storageKey); + if (existingData) { + const existing = JSON.parse(existingData) as Record>; + for (const [key, entry] of Object.entries(existing)) { + if (!this.pendingWrites.has(key) && (!entry.ttl || now - entry.timestamp < entry.ttl)) { + cacheData[key] = entry; + } + } + } - sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData)); - this.pendingWrites.clear(); - } catch (error) { - console.warn('Failed to write cache to sessionStorage:', error); - } - } + sessionStorage.setItem(this.storageKey, JSON.stringify(cacheData)); + this.pendingWrites.clear(); + } catch (error) { + console.warn('Failed to write cache to sessionStorage:', error); + } + } - get(key: string): T | undefined { - const entry = this.memoryCache.get(key); - if (!entry) return undefined; + get(key: string): T | undefined { + const entry = this.memoryCache.get(key); + if (!entry) return undefined; - if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { - this.delete(key); - return undefined; - } + if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { + this.delete(key); + return undefined; + } - return entry.data; - } + return entry.data; + } - set(key: string, data: T, ttlMs?: number): void { - const entry: CacheEntry = { - data, - timestamp: Date.now(), - ttl: ttlMs, - }; + set(key: string, data: T, ttlMs?: number): void { + const entry: CacheEntry = { + data, + timestamp: Date.now(), + ttl: ttlMs, + }; - this.memoryCache.set(key, entry); - this.pendingWrites.add(key); - this.debouncedWrite(); - } + this.memoryCache.set(key, entry); + this.pendingWrites.add(key); + this.debouncedWrite(); + } - delete(key: string): boolean { - const deleted = this.memoryCache.delete(key); - if (deleted) { - this.pendingWrites.add(key); - this.debouncedWrite(); - } - return deleted; - } + delete(key: string): boolean { + const deleted = this.memoryCache.delete(key); + if (deleted) { + this.pendingWrites.add(key); + this.debouncedWrite(); + } + return deleted; + } - clear(): void { - this.memoryCache.clear(); - try { - sessionStorage.removeItem(this.storageKey); - } catch (error) { - console.warn('Failed to clear sessionStorage:', error); - } - if (this.writeTimeout) { - clearTimeout(this.writeTimeout); - this.writeTimeout = null; - } - this.pendingWrites.clear(); - } + clear(): void { + this.memoryCache.clear(); + try { + sessionStorage.removeItem(this.storageKey); + } catch (error) { + console.warn('Failed to clear sessionStorage:', error); + } + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + this.writeTimeout = null; + } + this.pendingWrites.clear(); + } - has(key: string): boolean { - const entry = this.memoryCache.get(key); - if (!entry) return false; - - if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { - this.delete(key); - return false; - } - - return true; - } + has(key: string): boolean { + const entry = this.memoryCache.get(key); + if (!entry) return false; - get size(): number { - return this.memoryCache.size; - } + if (entry.ttl && Date.now() - entry.timestamp > entry.ttl) { + this.delete(key); + return false; + } - get bytes(): number { - return this.memoryCache.bytes; - } + return true; + } - forceWrite(): void { - if (this.writeTimeout) { - clearTimeout(this.writeTimeout); - this.writeTimeout = null; - } - this.writeToSessionStorage(); - } -} \ No newline at end of file + get size(): number { + return this.memoryCache.size; + } + + get bytes(): number { + return this.memoryCache.bytes; + } + + forceWrite(): void { + if (this.writeTimeout) { + clearTimeout(this.writeTimeout); + this.writeTimeout = null; + } + this.writeToSessionStorage(); + } +} diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index a8db535..a9b71fe 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -3,7 +3,6 @@ --> + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..41dd50d --- /dev/null +++ b/src/lib/components/ui/label/index.ts @@ -0,0 +1,3 @@ +import Label from './label.svelte'; + +export { Label }; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..e58197f --- /dev/null +++ b/src/lib/components/ui/label/label.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/search/index.ts b/src/lib/components/ui/search/index.ts new file mode 100644 index 0000000..a3762fd --- /dev/null +++ b/src/lib/components/ui/search/index.ts @@ -0,0 +1 @@ +export { default as Search } from './search.svelte'; diff --git a/src/lib/components/ui/search/search.svelte b/src/lib/components/ui/search/search.svelte new file mode 100644 index 0000000..5d0d351 --- /dev/null +++ b/src/lib/components/ui/search/search.svelte @@ -0,0 +1,13 @@ + + +
+ + +
diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte index fd845a7..7cbd77c 100644 --- a/src/lib/components/ui/sidebar/sidebar.svelte +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -2,12 +2,15 @@ import { cn } from '$lib/utils/utils'; import type { HTMLAttributes } from 'svelte/elements'; import { useSidebar } from './sidebar.svelte.js'; + import { shortcut } from '$lib/actions/shortcut.svelte.js'; let { children, ...rest }: HTMLAttributes = $props(); const sidebar = useSidebar(); + +
+ import { cn } from '$lib/utils/utils'; + import type { HTMLTextareaAttributes } from 'svelte/elements'; + + let { value = $bindable(''), class: className, ...rest }: HTMLTextareaAttributes = $props(); + + + diff --git a/src/lib/hooks/is-mac.svelte.ts b/src/lib/hooks/is-mac.svelte.ts new file mode 100644 index 0000000..512a5ed --- /dev/null +++ b/src/lib/hooks/is-mac.svelte.ts @@ -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'; diff --git a/src/lib/state/models.svelte.ts b/src/lib/state/models.svelte.ts index 17c340c..f301928 100644 --- a/src/lib/state/models.svelte.ts +++ b/src/lib/state/models.svelte.ts @@ -13,7 +13,7 @@ export class Models { init = createInit(() => { const query = useCachedQuery(api.user_enabled_models.get_enabled, { - user_id: session.current?.user.id ?? '', + session_token: session.current?.session.token ?? '', }); watch( () => $state.snapshot(query.data), diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 9048099..dc7b4fd 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,6 +1,6 @@ -import { redirect } from "@sveltejs/kit"; +import { redirect } from '@sveltejs/kit'; export async function load() { // temporary redirect to /chat - redirect(303, '/chat'); -} \ No newline at end of file + redirect(303, '/chat'); +} diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte index f48efe4..061882e 100644 --- a/src/routes/account/+layout.svelte +++ b/src/routes/account/+layout.svelte @@ -6,6 +6,8 @@ import { LightSwitch } from '$lib/components/ui/light-switch'; import ArrowLeftIcon from '~icons/lucide/arrow-left'; 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(); @@ -46,7 +48,7 @@
-
+
-
+
{#each navigation as tab (tab)} {tab.title} diff --git a/src/routes/account/api-keys/provider-card.svelte b/src/routes/account/api-keys/provider-card.svelte index d2396c8..aaf412e 100644 --- a/src/routes/account/api-keys/provider-card.svelte +++ b/src/routes/account/api-keys/provider-card.svelte @@ -62,7 +62,7 @@ - + {meta.title} diff --git a/src/routes/account/customization/+page.svelte b/src/routes/account/customization/+page.svelte new file mode 100644 index 0000000..a281cd0 --- /dev/null +++ b/src/routes/account/customization/+page.svelte @@ -0,0 +1,121 @@ + + + + Customization | Thom.chat + + +

Customization

+

Customize your experience with Thom.chat.

+ +
+
+

Rules

+ +
+ {#if newRuleCollapsible.open} +
+
+

New Rule

+

+ Create a new rule to customize the behavior of your AI. +

+
+
+
+ + +
+
+ + +
+
+ +