working chat

This commit is contained in:
Thomas G. Lopes 2025-06-16 18:50:51 +01:00
parent de4f4f6519
commit 7c216dc18b
33 changed files with 901 additions and 78 deletions

View file

@ -44,6 +44,7 @@ IDK, calm down
- [ ] Streams on the server - [ ] Streams on the server
- [ ] Syntax highlighting with Shiki/markdown renderer - [ ] Syntax highlighting with Shiki/markdown renderer
- [ ] Eliminate FOUC - [ ] Eliminate FOUC
- [ ] Cascade deletes and shit in Convex
### Extra ### Extra
@ -52,4 +53,4 @@ IDK, calm down
- [ ] Chat branching - [ ] Chat branching
- [ ] Image generation - [ ] Image generation
- [ ] Chat sharing - [ ] Chat sharing
- [ ] 404 page - [ ] 404 page/redirect

View file

@ -68,6 +68,8 @@
"@fontsource-variable/fraunces": "^5.2.7", "@fontsource-variable/fraunces": "^5.2.7",
"@fontsource-variable/geist-mono": "^5.2.6", "@fontsource-variable/geist-mono": "^5.2.6",
"@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/inter": "^5.2.6",
"better-auth": "^1.2.9" "better-auth": "^1.2.9",
"openai": "^5.3.0",
"zod": "^3.25.64"
} }
} }

23
pnpm-lock.yaml generated
View file

@ -23,6 +23,12 @@ importers:
better-auth: better-auth:
specifier: ^1.2.9 specifier: ^1.2.9
version: 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: devDependencies:
'@better-auth-kit/convex': '@better-auth-kit/convex':
specifier: ^1.2.2 specifier: ^1.2.2
@ -1809,6 +1815,18 @@ packages:
nwsapi@2.2.20: nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} 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: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -4022,6 +4040,11 @@ snapshots:
nwsapi@2.2.20: {} 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: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4

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 { internal } from './_generated/api';
import { ConvexHandler, type ConvexReturnType } from '@better-auth-kit/convex/handler'; import { ConvexHandler, type ConvexReturnType } from '@better-auth-kit/convex/handler';
import { v } from 'convex/values';
const { betterAuth, query, insert, update, delete_, count, getSession } = ConvexHandler({ const { betterAuth, query, insert, update, delete_, count, getSession } = ConvexHandler({
action, action,
@ -10,3 +11,34 @@ const { betterAuth, query, insert, update, delete_, count, getSession } = Convex
}) as ConvexReturnType; }) as ConvexReturnType;
export { betterAuth, query, insert, update, delete_, count, getSession }; 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 { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values'; import { type Infer, v } from 'convex/values';
import { Provider } from '../../../lib/types'; import { Provider } from '../../../lib/types';
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p))); 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({ export default defineSchema({
user_keys: defineTable({ user_keys: defineTable({
@ -26,20 +33,14 @@ export default defineSchema({
conversations: defineTable({ conversations: defineTable({
user_id: v.string(), user_id: v.string(),
title: v.string(), title: v.string(),
created_at: v.number(), }).index('by_user', ['user_id']),
updated_at: v.number(),
})
.index('by_user', ['user_id'])
.index('by_user_updated', ['user_id', 'updated_at']),
messages: defineTable({ messages: defineTable({
conversation_id: v.id('conversations'), conversation_id: v.string(),
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')), role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(), content: v.string(),
created_at: v.number(), // Optional, coming from SK API route
model_id: v.optional(v.string()), model_id: v.optional(v.string()),
provider: v.optional(providerValidator), provider: v.optional(providerValidator),
token_count: v.optional(v.number()), token_count: v.optional(v.number()),
}) }).index('by_conversation', ['conversation_id']),
.index('by_conversation', ['conversation_id'])
.index('by_conversation_created', ['conversation_id', 'created_at']),
}); });

View file

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

View file

@ -4,6 +4,11 @@ import { providerValidator } from './schema';
import * as array from '../../utils/array'; import * as array from '../../utils/array';
import * as object from '../../utils/object'; import * as object from '../../utils/object';
import { internal } from './_generated/api'; import { internal } from './_generated/api';
import { Provider } from '../../types';
export const getModelKey = (args: { provider: Provider; model_id: string }) => {
return `${args.provider}:${args.model_id}`;
};
export const get_enabled = query({ export const get_enabled = query({
args: { args: {
@ -15,7 +20,7 @@ export const get_enabled = query({
.withIndex('by_user', (q) => q.eq('user_id', args.user_id)) .withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.collect(); .collect();
return array.toMap(models, (m) => [`${m.provider}:${m.model_id}`, m]); return array.toMap(models, (m) => [getModelKey(m), m]);
}, },
}); });
@ -37,6 +42,24 @@ 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({ export const set = mutation({
args: { args: {
provider: providerValidator, provider: providerValidator,

View file

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

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 { useQuery as convexUseQuery } from 'convex-svelte';
import { SessionStorageCache } from './session-cache.js'; import { SessionStorageCache } from './session-cache.js';
import { getFunctionName, type FunctionReference, type OptionalRestArgs } from 'convex/server'; import { getFunctionName, type FunctionArgs, type FunctionReference } from 'convex/server';
import { watch } from 'runed'; import { extract, watch } from 'runed';
interface CachedQueryOptions { interface CachedQueryOptions {
cacheKey?: string; cacheKey?: string;
@ -19,36 +19,32 @@ interface QueryResult<T> {
const globalCache = new SessionStorageCache('convex-query-cache'); const globalCache = new SessionStorageCache('convex-query-cache');
export function useCachedQuery< export function useCachedQuery<Query extends FunctionReference<'query'>>(
Query extends FunctionReference<'query'>,
Args extends OptionalRestArgs<Query>,
>(
query: Query, query: Query,
...args: Args extends undefined ? [] : [Args[0], CachedQueryOptions?] queryArgs: FunctionArgs<Query> | (() => FunctionArgs<Query>),
options: CachedQueryOptions = {}
): QueryResult<Query['_returnType']> { ): QueryResult<Query['_returnType']> {
const [queryArgs, options = {}] = args as [Args[0]?, CachedQueryOptions?];
const { const {
cacheKey, cacheKey,
ttl = 7 * 24 * 60 * 60 * 1000, // 1 week default ttl = 7 * 24 * 60 * 60 * 1000, // 1 week default
staleWhileRevalidate = true, // staleWhileRevalidate = true,
enabled = true, enabled = true,
} = options; } = options;
// Generate cache key from query reference and args // 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 // 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 // Convex query, used as soon as possible
const convexResult = convexUseQuery(query, queryArgs, { const convexResult = convexUseQuery(query, queryArgs, {
// enabled: enabled && (!cachedData || !staleWhileRevalidate), // enabled: enabled && (!cachedData || !staleWhileRevalidate),
}); });
const shouldUseCached = $derived( const shouldUseCached = $derived(cachedData !== undefined && convexResult.isLoading);
cachedData !== undefined && (staleWhileRevalidate || convexResult.isLoading)
);
// Cache fresh data when available // Cache fresh data when available
watch( watch(
@ -80,17 +76,8 @@ export function invalidateQuery(query: FunctionReference<'query'>, queryArgs?: u
globalCache.delete(key); 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 { export function clearQueryCache(): void {
globalCache.clear(); globalCache.clear();
} }
export { globalCache as queryCache }; 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> = {}; const map: Record<string, V> = {};
for (let i = 0; i < arr.length; i++) { 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; 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>; 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> { export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
return Object.fromEntries( return Object.fromEntries(
Object.entries(obj).filter(([key]) => !keys.includes(key as K)) 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'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals }) => {
const session = await locals.auth(); const [session, openRouterModels] = await Promise.all([locals.auth(), getOpenRouterModels()]);
return { return {
session, session,
models: {
[Provider.OpenRouter]: openRouterModels.unwrapOr([] as OpenRouterModel[]),
},
}; };
}; };

View file

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

View file

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

View file

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

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

View file

@ -38,6 +38,7 @@
async function toggleEnabled(v: boolean) { async function toggleEnabled(v: boolean) {
enabled = v; // Optimistic! enabled = v; // Optimistic!
console.log('hi');
if (!session.current?.user.id) return; if (!session.current?.user.id) return;
const res = await ResultAsync.fromPromise( const res = await ResultAsync.fromPromise(

View file

@ -0,0 +1,189 @@
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 { 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';
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);
}
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
export const POST: RequestHandler = async ({ request }) => {
const bodyResult = await ResultAsync.fromPromise(
request.json(),
() => 'Failed to parse request body'
);
if (bodyResult.isErr()) {
return error(400, 'Failed to parse request body');
}
const parsed = reqBodySchema.safeParse(bodyResult.value);
if (!parsed.success) {
return error(400, parsed.error);
}
const args = parsed.data;
const session = await client.query(api.betterAuth.publicGetSession, {
session_token: args.session_token,
});
if (!session) {
throw new Error('Unauthorized');
}
const model = await client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter,
model_id: args.model_id,
user_id: session.userId,
});
if (!model) {
throw new Error('Model not found or not enabled');
}
let conversationId = args.conversation_id;
if (!conversationId) {
conversationId = await client.mutation(api.conversations.create, {
session_token: args.session_token,
});
}
if (args.message) {
await 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',
});
}
const messagesQuery = await ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: args.session_token,
}),
(e) => e
);
if (messagesQuery.isErr()) {
throw new Error('Failed to get messages');
}
const messages = messagesQuery.value;
console.log(messages);
const key = await client.query(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.token,
});
if (!key) {
throw new Error('No key found');
}
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: key,
});
const stream = await 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,
});
// Create first message
const mid = await client.mutation(api.messages.create, {
conversation_id: conversationId,
content: '',
role: 'assistant',
session_token: session.token,
});
async function handleStream() {
if (!session) return;
let content = '';
for await (const chunk of stream) {
content += chunk.choices[0]?.delta?.content || '';
if (!content) continue;
await client.mutation(api.messages.updateContent, {
message_id: mid,
content,
session_token: session.token,
});
}
}
handleStream();
return response({ ok: true, conversation_id: conversationId });
// const completionResult = await ResultAsync.fromPromise(
// openai.chat.completions.create({
// model,
// messages: [{ role: 'user', content: message }],
// max_tokens: 1000,
// temperature: 0.7,
// stream: true,
// }),
// () => 'OpenRouter API failed'
// );
//
// if (completionResult.isErr()) {
// return new Response(JSON.stringify({ error: completionResult.error }), {
// status: 500,
// headers: { 'Content-Type': 'application/json' },
// });
// }
//
// const stream = completionResult.value;
//
//
// const readable = new ReadableStream({
// async start(controller) {
// for await (const chunk of stream) {
// const content = chunk.choices[0]?.delta?.content || '';
// if (content) {
// controller.enqueue(new TextEncoder().encode(content));
// }
// }
// controller.close();
// },
// });
//
// return new Response(readable, {
// headers: {
// 'Content-Type': 'text/plain',
// 'Cache-Control': 'no-cache',
// },
// });
};

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"> <script lang="ts">
import * as Icons from '$lib/components/icons';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar'; 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 { 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 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 { 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> </script>
<svelte:head> <svelte:head>
@ -19,19 +57,23 @@
<span class="text-center font-serif text-lg">Thom.chat</span> <span class="text-center font-serif text-lg">Thom.chat</span>
</div> </div>
<Button href="/chat" class="w-full">New Chat</Button> <Button href="/chat" class="w-full">New Chat</Button>
<div class="flex flex-1 flex-col overflow-y-auto"> <div class="flex flex-1 flex-col overflow-y-auto py-2">
<!-- chats --> {#each conversationsQuery.data ?? [] as conversation (conversation._id)}
<a href={`/chat/${conversation._id}`} class="text-left hover:underline">
{conversation.title}
</a>
{/each}
</div> </div>
<div class="py-2"> <div class="py-2">
{#if data.session !== null} {#if data.session !== null}
<Button href="/account/api-keys" variant="ghost" class="h-auto w-full justify-start"> <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)} {#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" /> <img {...avatar.image} alt="Your avatar" class="size-10 rounded-full" />
<span {...avatar.fallback} <span {...avatar.fallback}
>{data.session?.user.name >{data.session?.user.name
.split(' ') .split(' ')
.map((name) => name[0].toUpperCase()) .map((name) => name[0]?.toUpperCase())
.join('')}</span .join('')}</span
> >
{/snippet} {/snippet}
@ -54,10 +96,29 @@
<div class="mx-auto flex size-full max-w-3xl flex-col"> <div class="mx-auto flex size-full max-w-3xl flex-col">
{@render children()} {@render children()}
<div class="mt-auto flex w-full flex-col gap-1"> <div class="mt-auto flex w-full flex-col gap-1">
<form class="relative h-18 w-full"> <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 <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" 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..." placeholder="Ask me anything..."
name="message"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
autofocus
autocomplete="off"
></textarea> ></textarea>
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8"> <Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
<SendIcon /> <SendIcon />

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, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"types": [ "types": [
"unplugin-icons/types/svelte" "unplugin-icons/types/svelte"