Merge pull request #8 from TGlide/chat

Chat!
This commit is contained in:
Thomas G. Lopes 2025-06-16 19:05:45 +01:00 committed by GitHub
commit a0d60b2053
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1175 additions and 76 deletions

View file

@ -43,6 +43,9 @@ IDK, calm down
- [ ] File support
- [ ] Streams on the server
- [ ] Syntax highlighting with Shiki/markdown renderer
- [ ] Eliminate FOUC
- [ ] Cascade deletes and shit in Convex
- [ ] Error notification central, specially for BYOK models like o3
### Extra
@ -51,3 +54,4 @@ IDK, calm down
- [ ] Chat branching
- [ ] Image generation
- [ ] Chat sharing
- [ ] 404 page/redirect

View file

@ -65,6 +65,11 @@
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
"better-auth": "^1.2.9"
"@fontsource-variable/fraunces": "^5.2.7",
"@fontsource-variable/geist-mono": "^5.2.6",
"@fontsource-variable/inter": "^5.2.6",
"better-auth": "^1.2.9",
"openai": "^5.3.0",
"zod": "^3.25.64"
}
}

47
pnpm-lock.yaml generated
View file

@ -11,9 +11,24 @@ importers:
'@floating-ui/dom':
specifier: ^1.7.1
version: 1.7.1
'@fontsource-variable/fraunces':
specifier: ^5.2.7
version: 5.2.7
'@fontsource-variable/geist-mono':
specifier: ^5.2.6
version: 5.2.6
'@fontsource-variable/inter':
specifier: ^5.2.6
version: 5.2.6
better-auth:
specifier: ^1.2.9
version: 1.2.9
openai:
specifier: ^5.3.0
version: 5.3.0(ws@8.18.2)(zod@3.25.64)
zod:
specifier: ^3.25.64
version: 3.25.64
devDependencies:
'@better-auth-kit/convex':
specifier: ^1.2.2
@ -564,6 +579,15 @@ packages:
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@fontsource-variable/fraunces@5.2.7':
resolution: {integrity: sha512-PYIcwL3+0SA2IEAcA9ma07Rz8DCbJdLy7Hb1syq/FIcCyf1zSlHRRcC0a33PnBCZ9Q7B+01kFH0cS29yqWEk3w==}
'@fontsource-variable/geist-mono@5.2.6':
resolution: {integrity: sha512-vw6T9JGTrYJ980bn7W8iTPhe2jVK5ifunVs7xh9dfTVArjDSkJs03JjeZrH5LKEpGABLXSlSlNU57HRm4tmFMg==}
'@fontsource-variable/inter@5.2.6':
resolution: {integrity: sha512-jks/bficUPQ9nn7GvXvHtlQIPudW7Wx8CrlZoY8bhxgeobNxlQan8DclUJuYF2loYRrGpfrhCIZZspXYysiVGg==}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
@ -1791,6 +1815,18 @@ packages:
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
openai@5.3.0:
resolution: {integrity: sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@ -2754,6 +2790,12 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@fontsource-variable/fraunces@5.2.7': {}
'@fontsource-variable/geist-mono@5.2.6': {}
'@fontsource-variable/inter@5.2.6': {}
'@hexagon/base64@1.1.28': {}
'@humanfs/core@0.19.1': {}
@ -3998,6 +4040,11 @@ snapshots:
nwsapi@2.2.20: {}
openai@5.3.0(ws@8.18.2)(zod@3.25.64):
optionalDependencies:
ws: 8.18.2
zod: 3.25.64
optionator@0.9.4:
dependencies:
deep-is: 0.1.4

View file

@ -1,4 +1,7 @@
@import 'tailwindcss';
@import '@fontsource-variable/inter';
@import '@fontsource-variable/geist-mono';
@import '@fontsource-variable/fraunces';
@custom-variant dark (&:where(.dark, .dark *));
@ -35,6 +38,14 @@
--sidebar-accent-foreground: oklch(0.3963 0.0251 285.1962);
--sidebar-border: oklch(0.9383 0.0026 48.7178);
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -53,8 +64,8 @@
--card-foreground: oklch(0.8456 0.0302 341.4597);
--popover: oklch(0.1548 0.0132 338.9015);
--popover-foreground: oklch(0.9647 0.0091 341.8035);
--primary: oklch(0.4607 0.1853 4.0994);
--primary-foreground: oklch(0.856 0.0618 346.3684);
--primary: oklch(0.5797 0.1194 237.7893);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.3137 0.0306 310.061);
--secondary-foreground: oklch(0.8483 0.0382 307.9613);
--muted: oklch(0.2634 0.0219 309.4748);
@ -65,7 +76,7 @@
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.3286 0.0154 343.4461);
--input: oklch(0.3387 0.0195 332.8347);
--ring: oklch(0.5916 0.218 0.5844);
--ring: oklch(0.5797 0.1194 237.7893);
--chart-1: oklch(0.5316 0.1409 355.1999);
--chart-2: oklch(0.5633 0.1912 306.8561);
--chart-3: oklch(0.7227 0.1502 60.5799);
@ -78,7 +89,15 @@
--sidebar-accent: oklch(0.2337 0.0261 338.1961);
--sidebar-accent-foreground: oklch(0.9674 0.0013 286.3752);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--sidebar-ring: oklch(0.5797 0.1194 237.7893);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -90,6 +109,59 @@
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@ -125,12 +197,13 @@
--color-sidebar-ring: var(--sidebar-ring);
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Inter Variable', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);

View file

@ -7,7 +7,7 @@
<title>Thom Chat</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="background: oklch(0.2409 0.0201 307.5346)">
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -1,6 +1,7 @@
import { action, internalQuery, internalMutation } from './_generated/server';
import { action, internalQuery, internalMutation, query as convexQuery } from './_generated/server';
import { internal } from './_generated/api';
import { ConvexHandler, type ConvexReturnType } from '@better-auth-kit/convex/handler';
import { v } from 'convex/values';
const { betterAuth, query, insert, update, delete_, count, getSession } = ConvexHandler({
action,
@ -10,3 +11,34 @@ const { betterAuth, query, insert, update, delete_, count, getSession } = Convex
}) as ConvexReturnType;
export { betterAuth, query, insert, update, delete_, count, getSession };
export type SessionObj = {
_creationTime: number;
_id: string;
expiresAt: string;
ipAddress: string;
token: string;
updatedAt: string;
userAgent: string;
userId: string;
};
export const publicGetSession = convexQuery({
args: {
session_token: v.string(),
},
handler: async (ctx, args) => {
const s = await ctx.runQuery(internal.betterAuth.getSession, {
sessionToken: args.session_token,
});
// Without this if, typescript goes bonkers
if (!s) {
return false;
}
// this is also needed. I don't know why :(
const ret = s as SessionObj;
return ret;
},
});

View file

@ -0,0 +1,82 @@
import { v } from 'convex/values';
import { Provider } from '../../types';
import { internal } from './_generated/api';
import { mutation, query } from './_generated/server';
import { providerValidator } from './schema';
export const all = query({
args: {
user_id: v.string(),
},
handler: async (ctx, args) => {
const allKeys = await ctx.db
.query('user_keys')
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.collect();
return Object.values(Provider).reduce(
(acc, key) => {
acc[key] = allKeys.find((item) => item.provider === key)?.key;
return acc;
},
{} as Record<Provider, string | undefined>
);
},
});
export const get = query({
args: {
user_id: v.string(),
provider: providerValidator,
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('Unauthorized');
}
const key = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>
q.eq('provider', args.provider).eq('user_id', args.user_id)
)
.first();
return key?.key;
},
});
export const set = mutation({
args: {
provider: providerValidator,
user_id: v.string(),
key: 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('Unauthorized');
}
const existing = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>
q.eq('provider', args.provider).eq('user_id', args.user_id)
)
.first();
if (existing) {
await ctx.db.replace(existing._id, args);
} else {
await ctx.db.insert('user_keys', args);
}
},
});

View file

@ -0,0 +1,51 @@
import { v } from 'convex/values';
import { api } from './_generated/api';
import { mutation, query } from './_generated/server';
import { type Id } from './_generated/dataModel';
import { type SessionObj } from './betterAuth';
export const get = query({
args: {
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const s = session as SessionObj;
const conversations = await ctx.db
.query('conversations')
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
.collect();
return conversations;
},
});
export const create = mutation({
args: {
session_token: v.string(),
},
handler: async (ctx, args): Promise<Id<'conversations'>> => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const res = await ctx.db.insert('conversations', {
title: 'Untitled (for now)',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
user_id: session.userId as any,
});
return res;
},
});

View file

@ -0,0 +1,102 @@
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';
export const getAllFromConversation = query({
args: {
conversation_id: v.string(),
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const messages = await ctx.db
.query('messages')
.withIndex('by_conversation', (q) => q.eq('conversation_id', args.conversation_id))
.order('asc')
.collect();
return messages;
},
});
export const create = mutation({
args: {
conversation_id: v.string(),
content: v.string(),
role: messageRoleValidator,
session_token: v.string(),
// Optional, coming from SK API route
model_id: v.optional(v.string()),
provider: v.optional(providerValidator),
token_count: v.optional(v.number()),
},
handler: async (ctx, args): Promise<Id<'messages'>> => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const messages = await ctx.runQuery(api.messages.getAllFromConversation, {
conversation_id: args.conversation_id,
session_token: args.session_token,
});
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === args.role) {
throw new Error('Last message has the same role, forbidden');
}
const id = await ctx.db.insert('messages', {
conversation_id: args.conversation_id,
content: args.content,
role: args.role,
// Optional, coming from SK API route
model_id: args.model_id,
provider: args.provider,
token_count: args.token_count,
});
return id;
},
});
export const updateContent = mutation({
args: {
session_token: v.string(),
message_id: v.string(),
content: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const message = await ctx.db.get(args.message_id as Id<'messages'>);
if (!message) {
throw new Error('Message not found');
}
await ctx.db.patch(message._id, {
content: args.content,
});
},
});

View file

@ -1,8 +1,15 @@
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
import { type Infer, v } from 'convex/values';
import { Provider } from '../../../lib/types';
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p)));
export const messageRoleValidator = v.union(
v.literal('user'),
v.literal('assistant'),
v.literal('system')
);
export type MessageRole = Infer<typeof messageRoleValidator>;
export default defineSchema({
user_keys: defineTable({
@ -23,4 +30,17 @@ 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']),
conversations: defineTable({
user_id: v.string(),
title: v.string(),
}).index('by_user', ['user_id']),
messages: defineTable({
conversation_id: v.string(),
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(),
// Optional, coming from SK API route
model_id: v.optional(v.string()),
provider: v.optional(providerValidator),
token_count: v.optional(v.number()),
}).index('by_conversation', ['conversation_id']),
});

View file

@ -11,6 +11,7 @@
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true,
/* These compiler options are required by Convex */
"target": "ESNext",

View file

@ -3,6 +3,12 @@ import { v } from 'convex/values';
import { providerValidator } from './schema';
import * as array from '../../utils/array';
import * as object from '../../utils/object';
import { internal } from './_generated/api';
import { Provider } from '../../types';
export const getModelKey = (args: { provider: Provider; model_id: string }) => {
return `${args.provider}:${args.model_id}`;
};
export const get_enabled = query({
args: {
@ -14,7 +20,7 @@ export const get_enabled = query({
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.collect();
return array.toMap(models, (m) => [`${m.provider}:${m.model_id}`, m]);
return array.toMap(models, (m) => [getModelKey(m), m]);
},
});
@ -36,14 +42,41 @@ export const is_enabled = query({
},
});
export const get = query({
args: {
provider: providerValidator,
model_id: v.string(),
user_id: v.string(),
},
handler: async (ctx, args) => {
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)
)
.first();
return model;
},
});
export const set = mutation({
args: {
provider: providerValidator,
model_id: v.string(),
user_id: v.string(),
enabled: v.boolean(),
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('Unauthorized');
}
const existing = await ctx.db
.query('user_enabled_models')
.withIndex('by_model_provider', (q) =>

View file

@ -1,16 +1,28 @@
import { v } from 'convex/values';
import { Provider } from '../../types';
import { api, internal } from './_generated/api';
import { mutation, query } from './_generated/server';
import { providerValidator } from './schema';
import { type SessionObj } from './betterAuth';
export const all = query({
args: {
user_id: v.string(),
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const s = session as SessionObj;
const allKeys = await ctx.db
.query('user_keys')
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
.collect();
return Object.values(Provider).reduce(
@ -25,15 +37,23 @@ export const all = query({
export const get = query({
args: {
user_id: v.string(),
provider: providerValidator,
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const s = session as SessionObj;
const key = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>
q.eq('provider', args.provider).eq('user_id', args.user_id)
)
.withIndex('by_provider_user', (q) => q.eq('provider', args.provider).eq('user_id', s.userId))
.first();
return key?.key;
@ -45,8 +65,17 @@ export const set = mutation({
provider: providerValidator,
user_id: v.string(),
key: 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('Unauthorized');
}
const existing = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>

View file

@ -0,0 +1,9 @@
import { Provider } from '$lib/types';
import { type OpenRouterModel } from './open-router';
export type ProviderModelMap = {
[Provider.OpenRouter]: OpenRouterModel;
[Provider.HuggingFace]: never;
[Provider.OpenAI]: never;
[Provider.Anthropic]: never;
};

View file

@ -1,7 +1,7 @@
import { useQuery as convexUseQuery } from 'convex-svelte';
import { SessionStorageCache } from './session-cache.js';
import { getFunctionName, type FunctionReference, type OptionalRestArgs } from 'convex/server';
import { watch } from 'runed';
import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
import { extract, watch } from 'runed';
interface CachedQueryOptions {
cacheKey?: string;
@ -19,36 +19,32 @@ interface QueryResult<T> {
const globalCache = new SessionStorageCache('convex-query-cache');
export function useCachedQuery<
Query extends FunctionReference<'query'>,
Args extends OptionalRestArgs<Query>,
>(
export function useCachedQuery<Query extends FunctionReference<'query'>>(
query: Query,
...args: Args extends undefined ? [] : [Args[0], CachedQueryOptions?]
queryArgs: FunctionArgs<Query> | (() => FunctionArgs<Query>),
options: CachedQueryOptions = {}
): QueryResult<Query['_returnType']> {
const [queryArgs, options = {}] = args as [Args[0]?, CachedQueryOptions?];
const {
cacheKey,
ttl = 7 * 24 * 60 * 60 * 1000, // 1 week default
staleWhileRevalidate = true,
// staleWhileRevalidate = true,
enabled = true,
} = options;
// Generate cache key from query reference and args
const key = cacheKey || `${getFunctionName(query)}:${JSON.stringify(queryArgs || {})}`;
const key = $derived(
cacheKey || `${getFunctionName(query)}:${JSON.stringify(extract(queryArgs))}`
);
// Get cached data
const cachedData = enabled ? globalCache.get(key) : undefined;
const cachedData = $derived(enabled ? globalCache.get(key) : undefined);
// Convex query, used as soon as possible
const convexResult = convexUseQuery(query, queryArgs, {
// enabled: enabled && (!cachedData || !staleWhileRevalidate),
});
const shouldUseCached = $derived(
cachedData !== undefined && (staleWhileRevalidate || convexResult.isLoading)
);
const shouldUseCached = $derived(cachedData !== undefined && convexResult.isLoading);
// Cache fresh data when available
watch(
@ -80,17 +76,8 @@ export function invalidateQuery(query: FunctionReference<'query'>, queryArgs?: u
globalCache.delete(key);
}
export function invalidateQueriesMatching(pattern: string | RegExp): void {
// Note: This is a simplified implementation
// In a real implementation, you'd need to track all cache keys
console.warn(
'invalidateQueriesMatching not fully implemented - consider using specific key invalidation'
);
}
export function clearQueryCache(): void {
globalCache.clear();
}
export { globalCache as queryCache };

View file

@ -0,0 +1,18 @@
export function createInit(cb: () => void) {
let called = $state(false);
function init() {
if (called) return;
called = true;
cb();
}
return Object.defineProperties(init, {
called: {
get() {
return called;
},
enumerable: true,
},
}) as typeof init & { readonly called: boolean };
}

View file

@ -0,0 +1,118 @@
import { on } from 'svelte/events';
import { createSubscriber } from 'svelte/reactivity';
type Serializer<T> = {
serialize: (value: T) => string;
deserialize: (value: string) => T | undefined;
};
type StorageType = 'local' | 'session';
function getStorage(storageType: StorageType, window: Window & typeof globalThis): Storage {
switch (storageType) {
case 'local':
return window.localStorage;
case 'session':
return window.sessionStorage;
}
}
type PersistedObjOptions<T> = {
/** The storage type to use. Defaults to `local`. */
storage?: StorageType;
/** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */
serializer?: Serializer<T>;
/** Whether to sync with the state changes from other tabs. Defaults to `true`. */
syncTabs?: boolean;
};
export function createPersistedObj<T extends object>(
key: string,
initialValue: T,
options: PersistedObjOptions<T> = {}
): T {
const {
storage: storageType = 'local',
serializer = { serialize: JSON.stringify, deserialize: JSON.parse },
syncTabs = true,
} = options;
let current = initialValue;
let storage: Storage | undefined;
let subscribe: VoidFunction | undefined;
let version = $state(0);
if (typeof window !== 'undefined') {
storage = getStorage(storageType, window);
const existingValue = storage.getItem(key);
if (existingValue !== null) {
const deserialized = deserialize(existingValue);
if (deserialized) current = deserialized;
} else {
serialize(initialValue);
}
if (syncTabs && storageType === 'local') {
subscribe = createSubscriber(() => {
return on(window, 'storage', handleStorageEvent);
});
}
}
function handleStorageEvent(event: StorageEvent): void {
if (event.key !== key || event.newValue === null) return;
const deserialized = deserialize(event.newValue);
if (deserialized) current = deserialized;
version += 1;
}
function deserialize(value: string): T | undefined {
try {
return serializer.deserialize(value);
} catch (error) {
console.error(`Error when parsing "${value}" from persisted store "${key}"`, error);
return;
}
}
function serialize(value: T | undefined): void {
try {
if (value != undefined) {
storage?.setItem(key, serializer.serialize(value));
}
} catch (error) {
console.error(`Error when writing value from persisted store "${key}" to ${storage}`, error);
}
}
const proxies = new WeakMap();
const root = current;
const proxy = (value: unknown) => {
if (value === null || value?.constructor.name === 'Date' || typeof value !== 'object') {
return value;
}
let p = proxies.get(value);
if (!p) {
p = new Proxy(value, {
get: (target, property) => {
subscribe?.();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
version;
return proxy(Reflect.get(target, property));
},
set: (target, property, value) => {
version += 1;
Reflect.set(target, property, value);
serialize(root);
return true;
},
});
proxies.set(value, p);
}
return p;
};
return proxy(root);
}

View file

@ -0,0 +1,36 @@
import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api';
import { getModelKey } from '$lib/backend/convex/user_enabled_models';
import type { ProviderModelMap } from '$lib/backend/models/all';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { createInit } from '$lib/spells/create-init.svelte';
import { Provider } from '$lib/types';
import { watch } from 'runed';
import { session } from './session.svelte';
export class Models {
enabled = $state({} as Record<string, unknown>);
init = createInit(() => {
const query = useCachedQuery(api.user_enabled_models.get_enabled, {
user_id: session.current?.user.id ?? '',
});
watch(
() => $state.snapshot(query.data),
(data) => {
if (data) this.enabled = data;
}
);
});
from<P extends Provider>(provider: Provider) {
return page.data.models[provider].map((m: { id: string }) => {
return {
...m,
enabled: this.enabled[getModelKey({ provider, model_id: m.id })] !== undefined,
};
}) as Array<ProviderModelMap[P] & { enabled: boolean }>;
}
}
export const models = new Models();

View file

@ -0,0 +1,5 @@
import { createPersistedObj } from '$lib/spells/persisted-obj.svelte';
export const settings = createPersistedObj('settings', {
modelId: undefined as string | undefined,
});

View file

@ -71,7 +71,7 @@ export function toMap<T, V>(
const map: Record<string, V> = {};
for (let i = 0; i < arr.length; i++) {
const [key, value] = fn(arr[i], i);
const [key, value] = fn(arr[i]!, i);
map[key] = value;
}

3
src/lib/utils/is.ts Normal file
View file

@ -0,0 +1,3 @@
export function isString(value: unknown): value is string {
return typeof value === 'string';
}

View file

@ -3,6 +3,11 @@ export function keys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
// typed object.entries
export function entries<T extends object>(obj: T): Array<[keyof T, T[keyof T]]> {
return Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
}
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key as K))

View file

@ -1,10 +1,15 @@
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
import { Provider } from '$lib/types';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const session = await locals.auth();
const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]);
return {
session,
models: {
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
},
};
};

View file

@ -3,10 +3,12 @@
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { models } from '$lib/state/models.svelte';
let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
models.init();
</script>
<ModeWatcher />

View file

@ -55,7 +55,7 @@
<span {...avatar.fallback}>
{data.session.user.name
.split(' ')
.map((i) => i[0].toUpperCase())
.map((i) => i[0]?.toUpperCase())
.join('')}
</span>
{/snippet}

View file

@ -21,8 +21,8 @@
const id = $props.id();
const keyQuery = useCachedQuery(api.user_keys.get, {
user_id: session.current?.user.id ?? '',
provider,
session_token: session.current?.session.token ?? '',
});
const client = useConvexClient();
@ -44,6 +44,7 @@
provider,
user_id: session.current?.user.id ?? '',
key: `${key}`,
session_token: session.current?.session.token,
}),
(e) => e
);

View file

@ -1,7 +0,0 @@
import { getOpenRouterModels, type OpenRouterModel } from '$lib/backend/models/open-router';
export async function load() {
return {
openRouterModels: (await getOpenRouterModels()).unwrapOr([] as OpenRouterModel[]),
};
}

View file

@ -1,15 +1,7 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { session } from '$lib/state/session.svelte';
import { models } from '$lib/state/models.svelte';
import { Provider } from '$lib/types.js';
import ModelCard from './model-card.svelte';
let { data } = $props();
const enabledModels = useCachedQuery(api.user_enabled_models.get_enabled, {
user_id: session.current?.user.id ?? '',
});
</script>
<svelte:head>
@ -22,8 +14,7 @@
</h2>
<div class="mt-8 flex flex-col gap-4">
{#each data.openRouterModels as model (model.id)}
{@const enabled = enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined}
<ModelCard provider={Provider.OpenRouter} {model} {enabled} />
{#each models.from(Provider.OpenRouter) as model (model.id)}
<ModelCard provider={Provider.OpenRouter} {model} enabled={model.enabled} />
{/each}
</div>

View file

@ -38,6 +38,7 @@
async function toggleEnabled(v: boolean) {
enabled = v; // Optimistic!
console.log('hi');
if (!session.current?.user.id) return;
const res = await ResultAsync.fromPromise(
@ -46,6 +47,7 @@
user_id: session.current.user.id,
model_id: model.id,
enabled: v,
session_token: session.current?.session.token,
}),
(e) => e
);

View file

@ -0,0 +1,280 @@
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 { 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 { z } from 'zod/v4';
// Set to true to enable debug logging
const ENABLE_LOGGING = true;
const reqBodySchema = z.object({
message: z.string(),
model_id: z.string(),
session_token: z.string(),
conversation_id: z.string().optional(),
});
export type GenerateMessageRequestBody = z.infer<typeof reqBodySchema>;
export type GenerateMessageResponse = {
ok: true;
conversation_id: string;
};
function response(res: GenerateMessageResponse) {
return json(res);
}
function log(message: string, startTime: number): void {
if (!ENABLE_LOGGING) return;
const elapsed = Date.now() - startTime;
console.log(`[GenerateMessage] ${message} (${elapsed}ms)`);
}
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
async function generateAIResponse(
conversationId: string,
session: SessionObj,
modelId: string,
startTime: number
) {
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,
}),
(e) => `Failed to get model: ${e}`
);
if (modelResult.isErr()) {
log(`Background model query failed: ${modelResult.error}`, startTime);
return;
}
const model = modelResult.value;
if (!model) {
log('Background: Model not found or not enabled', startTime);
return;
}
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}`
);
if (messagesQuery.isErr()) {
log(`Background messages query failed: ${messagesQuery.error}`, startTime);
return;
}
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;
}
const key = keyResult.value;
if (!key) {
log('Background: No API key found', startTime);
return;
}
log('Background: API key retrieved successfully', startTime);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: key,
});
const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create({
model: model.model_id,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
max_tokens: 1000,
temperature: 0.7,
stream: true,
}),
(e) => `OpenAI API call failed: ${e}`
);
if (streamResult.isErr()) {
log(`Background OpenAI stream creation failed: ${streamResult.error}`, startTime);
return;
}
const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime);
// Create assistant message
const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, {
conversation_id: conversationId,
content: '',
role: 'assistant',
session_token: session.token,
}),
(e) => `Failed to create assistant message: ${e}`
);
if (messageCreationResult.isErr()) {
log(`Background assistant message creation failed: ${messageCreationResult.error}`, startTime);
return;
}
const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime);
let content = '';
let chunkCount = 0;
try {
for await (const chunk of stream) {
chunkCount++;
content += chunk.choices[0]?.delta?.content || '';
if (!content) continue;
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
session_token: session.token,
}),
(e) => `Failed to update message content: ${e}`
);
if (updateResult.isErr()) {
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
}
}
log(
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
startTime
);
} catch (error) {
log(`Background stream processing error: ${error}`, startTime);
}
}
export const POST: RequestHandler = async ({ request }) => {
const startTime = Date.now();
log('Starting message generation request', startTime);
const bodyResult = await ResultAsync.fromPromise(
request.json(),
() => 'Failed to parse request body'
);
if (bodyResult.isErr()) {
log(`Request body parsing failed: ${bodyResult.error}`, startTime);
return error(400, 'Failed to parse request body');
}
log('Request body parsed successfully', startTime);
const parsed = reqBodySchema.safeParse(bodyResult.value);
if (!parsed.success) {
log(`Schema validation failed: ${parsed.error}`, startTime);
return error(400, parsed.error);
}
const args = parsed.data;
log('Schema validation passed', startTime);
const sessionResult = await ResultAsync.fromPromise(
client.query(api.betterAuth.publicGetSession, {
session_token: args.session_token,
}),
(e) => `Failed to get session: ${e}`
);
if (sessionResult.isErr()) {
log(`Session query failed: ${sessionResult.error}`, startTime);
return error(401, 'Failed to authenticate');
}
const session = sessionResult.value;
if (!session) {
log('No session found - unauthorized', startTime);
return error(401, 'Unauthorized');
}
log('Session authenticated successfully', startTime);
let conversationId = args.conversation_id;
if (!conversationId) {
const conversationResult = await ResultAsync.fromPromise(
client.mutation(api.conversations.create, {
session_token: args.session_token,
}),
(e) => `Failed to create conversation: ${e}`
);
if (conversationResult.isErr()) {
log(`Conversation creation failed: ${conversationResult.error}`, startTime);
return error(500, 'Failed to create conversation');
}
conversationId = conversationResult.value;
log('New conversation created', startTime);
} else {
log('Using existing conversation', startTime);
}
if (args.message) {
const userMessageResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, {
conversation_id: conversationId as Id<'conversations'>,
content: args.message,
session_token: args.session_token,
model_id: args.model_id,
role: 'user',
}),
(e) => `Failed to create user message: ${e}`
);
if (userMessageResult.isErr()) {
log(`User message creation failed: ${userMessageResult.error}`, startTime);
return error(500, 'Failed to create user message');
}
log('User message created', startTime);
}
// Start AI response generation in background - don't await
generateAIResponse(conversationId, session, args.model_id, startTime).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 });
};

View file

@ -0,0 +1,17 @@
import { ResultAsync } from 'neverthrow';
import type { GenerateMessageRequestBody, GenerateMessageResponse } from './+server';
export async function callGenerateMessage(args: GenerateMessageRequestBody) {
const res = ResultAsync.fromPromise(
fetch('/api/generate-message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(args),
}),
(e) => e
).map((r) => r.json() as Promise<GenerateMessageResponse>);
return res;
}

View file

@ -1,12 +1,50 @@
<script lang="ts">
import * as Icons from '$lib/components/icons';
import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar';
import PanelLeftIcon from '~icons/lucide/panel-left';
import { session } from '$lib/state/session.svelte.js';
import { settings } from '$lib/state/settings.svelte.js';
import { isString } from '$lib/utils/is.js';
import { Avatar } from 'melt/components';
import * as Icons from '$lib/components/icons';
import PanelLeftIcon from '~icons/lucide/panel-left';
import SendIcon from '~icons/lucide/send';
import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
import { api } from '$lib/backend/convex/_generated/api.js';
let { data, children } = $props();
let form = $state<HTMLFormElement>();
let textarea = $state<HTMLTextAreaElement>();
async function handleSubmit() {
const formData = new FormData(form);
const message = formData.get('message');
// TODO: Re-use zod here from server endpoint for better error messages?
if (!isString(message) || !session.current?.user.id || !settings.modelId) return;
if (textarea) textarea.value = '';
const res = await callGenerateMessage({
message,
session_token: session.current?.session.token,
conversation_id: page.params.id ?? undefined,
model_id: settings.modelId,
});
if (res.isErr()) return; // TODO: Handle error
const cid = res.value.conversation_id;
if (page.params.id !== cid) {
goto(`/chat/${cid}`);
}
}
const conversationsQuery = useCachedQuery(api.conversations.get, {
session_token: session.current?.session.token ?? '',
});
</script>
<svelte:head>
@ -16,22 +54,26 @@
<Sidebar.Root>
<Sidebar.Sidebar class="flex flex-col p-2">
<div class="flex place-items-center justify-center py-2">
<span class="text-center text-lg font-bold">Thom Chat</span>
<span class="text-center font-serif text-lg">Thom.chat</span>
</div>
<Button href="/chat" class="w-full">New Chat</Button>
<div class="flex flex-1 flex-col overflow-y-auto">
<!-- chats -->
<div class="flex flex-1 flex-col overflow-y-auto py-2">
{#each conversationsQuery.data ?? [] as conversation (conversation._id)}
<a href={`/chat/${conversation._id}`} class="text-left hover:underline">
{conversation.title}
</a>
{/each}
</div>
<div class="py-2">
{#if data.session !== null}
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start">
<Avatar src={data.session.user.image ?? undefined}>
<Avatar src={data.session?.user.image ?? undefined}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback}
>{data.session?.user.name
.split(' ')
.map((name) => name[0].toUpperCase())
.map((name) => name[0]?.toUpperCase())
.join('')}</span
>
{/snippet}
@ -51,12 +93,32 @@
<Sidebar.Trigger class="fixed top-3 left-2">
<PanelLeftIcon />
</Sidebar.Trigger>
<div class="flex size-full place-items-center justify-center">
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
<form class="relative h-18 w-full">
<div class="mx-auto flex size-full max-w-3xl flex-col">
{@render children()}
<div class="mt-auto flex w-full flex-col gap-1">
<ModelPicker class=" w-min " />
<div class="h-2" aria-hidden="true"></div>
<form
class="relative h-18 w-full"
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
bind:this={form}
>
<textarea
bind:this={textarea}
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
placeholder="Ask me anything..."
name="message"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
autofocus
autocomplete="off"
></textarea>
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
<SendIcon />

View file

@ -1,2 +1,32 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { session } from '$lib/state/session.svelte';
import IconAi from '~icons/lucide/sparkles';
</script>
<div class="flex h-full flex-1 flex-col items-center justify-center">
<div class="w-full p-2">
<h2 class="text-left font-serif text-3xl font-semibold">Hey there, Bozo!</h2>
<p class="mt-2 text-left text-lg">
{#if session.current?.user.name}
Oops, I meant {session.current?.user.name}.
{:else}
Be sure to login first.
{/if}
</p>
<div class="mt-4 flex items-center gap-1">
{#each { length: 4 }}
<Button variant="outline" class="rounded-full">
<IconAi />
Create
</Button>
{/each}
</div>
<ul class="mt-2 flex flex-col gap-2 p-2">
{#each { length: 3 } as _, i (i)}
<li class={['py-2', i !== 2 && 'border-b']}>Hey AI, write me a poem</li>
{/each}
</ul>
</div>
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/state';
import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { session } from '$lib/state/session.svelte';
const messages = useCachedQuery(api.messages.getAllFromConversation, () => ({
conversation_id: page.params.id ?? '',
session_token: session.current?.session.token ?? '',
}));
</script>
<div class="flex h-full flex-1 flex-col overflow-x-clip overflow-y-auto py-4">
{#each messages.data ?? [] as message (message._id)}
{#if message.role === 'user'}
<div class="max-w-[80%] self-end bg-blue-900 p-2 text-white">
{message.content}
</div>
{:else if message.role === 'assistant'}
<div class="max-w-[80%] p-2 text-white">
{message.content}
</div>
{/if}
{/each}
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { api } from '$lib/backend/convex/_generated/api';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { session } from '$lib/state/session.svelte';
import { settings } from '$lib/state/settings.svelte';
type Props = {
class?: string;
};
let { class: className }: Props = $props();
const enabledModelsQuery = useCachedQuery(api.user_enabled_models.get_enabled, {
user_id: session.current?.user.id ?? '',
});
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
$effect(() => {
if (!enabledArr.find((m) => m.model_id === settings.modelId) && enabledArr.length > 0) {
settings.modelId = enabledArr[0]!.model_id;
}
});
</script>
<select bind:value={settings.modelId} class="border {className}">
{#each enabledArr as model (model._id)}
<option value={model.model_id}>{model.model_id}</option>
{/each}
</select>

View file

@ -9,6 +9,7 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler",
"types": [
"unplugin-icons/types/svelte"