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
bun.lock
bun.lockb
# Convex formats this
convex.json

View file

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

View file

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

68
pnpm-lock.yaml generated
View file

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

View file

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

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!,
},
},
// databaseHooks: {
// user: {
// create: {
// after: async ({ user }) => {
// // TODO: automatically enable default models for the user
// },
// },
// },
// },
databaseHooks: {
user: {
create: {
after: async (_user) => {},
},
},
},
plugins: [],
});

View file

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

View file

@ -11,6 +11,8 @@ export const messageRoleValidator = v.union(
export type MessageRole = Infer<typeof messageRoleValidator>;
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(),

View file

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

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 { extract, watch } from 'runed';
interface CachedQueryOptions {
export interface CachedQueryOptions {
cacheKey?: string;
ttl?: number;
staleWhileRevalidate?: boolean;
enabled?: boolean;
}
interface QueryResult<T> {
export interface QueryResult<T> {
data: T | undefined;
error: Error | undefined;
isLoading: boolean;

View file

@ -13,11 +13,7 @@ export class SessionStorageCache<T = unknown> {
private debounceMs: number;
private pendingWrites = new Set<string>();
constructor(
storageKey = 'query-cache',
maxSizeBytes = 1024 * 1024,
debounceMs = 300
) {
constructor(storageKey = 'query-cache', maxSizeBytes = 1024 * 1024, debounceMs = 300) {
this.storageKey = storageKey;
this.debounceMs = debounceMs;
this.memoryCache = new LRUCache<string, CacheEntry<T>>(maxSizeBytes);

View file

@ -3,7 +3,6 @@
-->
<script lang="ts" module>
import type { WithChildren, WithoutChildren } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
@ -36,7 +35,7 @@
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonPropsWithoutHTML = WithChildren<{
export type ButtonPropsWithoutHTML = {
ref?: HTMLElement | null;
variant?: ButtonVariant;
size?: ButtonSize;
@ -46,17 +45,18 @@
currentTarget: EventTarget & HTMLButtonElement;
}
) => Promise<void>;
}>;
children?: Snippet<[]>;
};
export type AnchorElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLAnchorAttributes, 'href' | 'type'>> & {
Omit<HTMLAnchorAttributes, 'href' | 'type' | 'children'> & {
href: HTMLAnchorAttributes['href'];
type?: never;
disabled?: HTMLButtonAttributes['disabled'];
};
export type ButtonElementProps = ButtonPropsWithoutHTML &
WithoutChildren<Omit<HTMLButtonAttributes, 'type' | 'href'>> & {
Omit<HTMLButtonAttributes, 'type' | 'href' | 'children'> & {
type?: HTMLButtonAttributes['type'];
href?: never;
disabled?: HTMLButtonAttributes['disabled'];
@ -68,6 +68,7 @@
<script lang="ts">
import { cn } from '$lib/utils/utils.js';
import LoaderCircleIcon from '~icons/lucide/loader-circle';
import type { Snippet } from 'svelte';
let {
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 type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './sidebar.svelte.js';
import { shortcut } from '$lib/actions/shortcut.svelte.js';
let { children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
const sidebar = useSidebar();
</script>
<svelte:window use:shortcut={{ key: 'b', ctrl: true, callback: sidebar.toggle }} />
<div
{...rest}
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(() => {
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),

View file

@ -1,4 +1,4 @@
import { redirect } from "@sveltejs/kit";
import { redirect } from '@sveltejs/kit';
export async function load() {
// temporary redirect to /chat

View file

@ -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 @@
<Button variant="ghost" onClickPromise={signOut}>Sign out</Button>
</div>
</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="flex flex-col place-items-center gap-2">
<Avatar src={data.session.user.image ?? undefined}>
@ -64,17 +66,30 @@
<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>
</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 class="pl-12 md:col-start-2">
</div>
</div>
</div>
</div>
<div class="md:col-start-2 md:pl-12">
<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)}
<a
href={tab.href}
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}
</a>

View file

@ -62,7 +62,7 @@
<Card.Root>
<Card.Header>
<Card.Title>
<Card.Title id={provider}>
<KeyIcon class="inline size-4" />
{meta.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">
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 { cn } from '$lib/utils/utils';
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>
<svelte:head>
@ -13,8 +48,49 @@
Choose which models appear in your model selector. This won't affect existing conversations.
</h2>
<div class="mt-8 flex flex-col gap-4">
{#each models.from(Provider.OpenRouter) as model (model.id)}
<ModelCard provider={Provider.OpenRouter} {model} enabled={model.enabled} />
{/each}
<div class="mt-4 flex flex-col gap-2">
<Search bind:value={search} placeholder="Search models" />
<div class="flex place-items-center gap-2">
<button
{...openRouterToggle.trigger}
aria-label="OpenRouter"
class="group text-primary-foreground bg-primary aria-[pressed=false]:border-border border-primary aria-[pressed=false]:bg-background flex place-items-center gap-1 rounded-full border px-2 py-1 text-xs transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
OpenRouter
<XIcon class="inline size-3 group-aria-[pressed=false]:hidden" />
<PlusIcon class="inline size-3 group-aria-[pressed=true]:hidden" />
</button>
</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;
model: Model;
enabled?: boolean;
disabled?: boolean;
};
let { provider, model, enabled = false }: Props = $props();
let { provider, model, enabled = false, disabled = false }: Props = $props();
const client = useConvexClient();
@ -38,13 +39,11 @@
async function toggleEnabled(v: boolean) {
enabled = v; // Optimistic!
console.log('hi');
if (!session.current?.user.id) return;
const res = await ResultAsync.fromPromise(
client.mutation(api.user_enabled_models.set, {
provider,
user_id: session.current.user.id,
model_id: model.id,
enabled: v,
session_token: session.current?.session.token,
@ -60,8 +59,7 @@
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title>{model.name}</Card.Title>
<!-- TODO: make this actually work -->
<Switch bind:value={() => enabled, toggleEnabled} />
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div>
<Card.Description
>{showMore ? fullDescription : (shortDescription ?? fullDescription)}</Card.Description
@ -71,6 +69,7 @@
type="button"
class="text-muted-foreground w-fit text-start text-xs"
onclick={() => (showMore = !showMore)}
{disabled}
>
{showMore ? 'Show less' : 'Show more'}
</button>

View file

@ -1,12 +1,13 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public';
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 { Provider } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ConvexHttpClient } from 'convex/browser';
import { ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { waitUntil } from '@vercel/functions';
import { z } from 'zod/v4';
@ -40,22 +41,32 @@ function log(message: string, startTime: number): void {
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
async function generateAIResponse(
conversationId: string,
session: SessionObj,
modelId: string,
startTime: number
) {
async function generateAIResponse({
conversationId,
session,
startTime,
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);
const modelResult = await ResultAsync.fromPromise(
client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter,
model_id: modelId,
user_id: session.userId,
const [modelResult, keyResult, messagesQueryResult] = await Promise.all([
modelResultPromise,
keyResultPromise,
ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.token,
}),
(e) => `Failed to get model: ${e}`
);
(e) => `Failed to get messages: ${e}`
),
]);
if (modelResult.isErr()) {
log(`Background model query failed: ${modelResult.error}`, startTime);
@ -70,13 +81,7 @@ async function generateAIResponse(
log('Background: Model found and enabled', startTime);
const messagesQuery = await ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: session.token,
}),
(e) => `Failed to get messages: ${e}`
);
const messagesQuery = await messagesQueryResult;
if (messagesQuery.isErr()) {
log(`Background messages query failed: ${messagesQuery.error}`, startTime);
@ -86,14 +91,6 @@ async function generateAIResponse(
const messages = messagesQuery.value;
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()) {
log(`Background API key query failed: ${keyResult.error}`, startTime);
return;
@ -228,6 +225,23 @@ export const POST: RequestHandler = async ({ request }) => {
return error(401, 'Unauthorized');
}
const modelResultPromise = ResultAsync.fromPromise(
client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter,
model_id: args.model_id,
session_token: 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);
let conversationId = args.conversation_id;
@ -271,10 +285,31 @@ export const POST: RequestHandler = async ({ request }) => {
}
// Start AI response generation in background - don't await
generateAIResponse(conversationId, session, args.model_id, startTime).catch((error) => {
waitUntil(
generateAIResponse({
conversationId,
session,
startTime,
modelResultPromise,
keyResultPromise,
}).catch((error) => {
log(`Background AI response generation error: ${error}`, startTime);
});
})
);
log('Response sent, AI generation started in background', startTime);
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">
<PanelLeftIcon />
</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()}
<div class="mt-auto flex w-full flex-col gap-1">
<ModelPicker class=" w-min " />
@ -124,7 +124,7 @@
<SendIcon />
</Button>
</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">
Crafted by <Icons.Svelte class="inline size-3" /> wizards.
</span>

View file

@ -11,7 +11,7 @@
let { class: className }: Props = $props();
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 ?? {}));

View file

@ -11,9 +11,7 @@
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler",
"types": [
"unplugin-icons/types/svelte"
]
"types": ["unplugin-icons/types/svelte"]
}
// 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