diff --git a/README.md b/README.md index 4f929ad..b3ba06d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ IDK, calm down - [ ] Streams on the server - [ ] Syntax highlighting with Shiki/markdown renderer - [ ] Eliminate FOUC +- [ ] Cascade deletes and shit in Convex ### Extra @@ -52,4 +53,4 @@ IDK, calm down - [ ] Chat branching - [ ] Image generation - [ ] Chat sharing -- [ ] 404 page +- [ ] 404 page/redirect diff --git a/package.json b/package.json index ec3043b..4da316a 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,8 @@ "@fontsource-variable/fraunces": "^5.2.7", "@fontsource-variable/geist-mono": "^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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5a8396..4871b90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: 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 @@ -1809,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'} @@ -4022,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 diff --git a/src/lib/backend/convex/betterAuth.ts b/src/lib/backend/convex/betterAuth.ts index b518756..f9615bb 100644 --- a/src/lib/backend/convex/betterAuth.ts +++ b/src/lib/backend/convex/betterAuth.ts @@ -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; + }, +}); diff --git a/src/lib/backend/convex/chat.ts b/src/lib/backend/convex/chat.ts new file mode 100644 index 0000000..92cb5dd --- /dev/null +++ b/src/lib/backend/convex/chat.ts @@ -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 + ); + }, +}); + +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); + } + }, +}); diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts new file mode 100644 index 0000000..2cdde2d --- /dev/null +++ b/src/lib/backend/convex/conversations.ts @@ -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> => { + 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; + }, +}); diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts new file mode 100644 index 0000000..c15b58e --- /dev/null +++ b/src/lib/backend/convex/messages.ts @@ -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> => { + 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, + }); + }, +}); diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index ad1af2e..77cb093 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -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; export default defineSchema({ user_keys: defineTable({ @@ -26,20 +33,14 @@ export default defineSchema({ conversations: defineTable({ user_id: v.string(), title: v.string(), - created_at: v.number(), - updated_at: v.number(), - }) - .index('by_user', ['user_id']) - .index('by_user_updated', ['user_id', 'updated_at']), + }).index('by_user', ['user_id']), messages: defineTable({ - conversation_id: v.id('conversations'), + conversation_id: v.string(), role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')), content: v.string(), - created_at: v.number(), + // 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']) - .index('by_conversation_created', ['conversation_id', 'created_at']), + }).index('by_conversation', ['conversation_id']), }); diff --git a/src/lib/backend/convex/tsconfig.json b/src/lib/backend/convex/tsconfig.json index a84f928..534259a 100644 --- a/src/lib/backend/convex/tsconfig.json +++ b/src/lib/backend/convex/tsconfig.json @@ -11,6 +11,7 @@ "jsx": "react-jsx", "skipLibCheck": true, "allowSyntheticDefaultImports": true, + "noUncheckedIndexedAccess": true, /* These compiler options are required by Convex */ "target": "ESNext", diff --git a/src/lib/backend/convex/user_enabled_models.ts b/src/lib/backend/convex/user_enabled_models.ts index 5ee3182..d4650b9 100644 --- a/src/lib/backend/convex/user_enabled_models.ts +++ b/src/lib/backend/convex/user_enabled_models.ts @@ -4,6 +4,11 @@ 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: { @@ -15,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]); }, }); @@ -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({ args: { provider: providerValidator, diff --git a/src/lib/backend/convex/user_keys.ts b/src/lib/backend/convex/user_keys.ts index 92cb5dd..4b443a4 100644 --- a/src/lib/backend/convex/user_keys.ts +++ b/src/lib/backend/convex/user_keys.ts @@ -1,17 +1,28 @@ import { v } from 'convex/values'; import { Provider } from '../../types'; -import { internal } from './_generated/api'; +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( @@ -26,24 +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(internal.betterAuth.getSession, { - sessionToken: args.session_token, + 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; diff --git a/src/lib/backend/models/all.ts b/src/lib/backend/models/all.ts new file mode 100644 index 0000000..b4a8a49 --- /dev/null +++ b/src/lib/backend/models/all.ts @@ -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; +}; diff --git a/src/lib/cache/cached-query.svelte.ts b/src/lib/cache/cached-query.svelte.ts index 4f2756f..959fb8d 100644 --- a/src/lib/cache/cached-query.svelte.ts +++ b/src/lib/cache/cached-query.svelte.ts @@ -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 { const globalCache = new SessionStorageCache('convex-query-cache'); -export function useCachedQuery< - Query extends FunctionReference<'query'>, - Args extends OptionalRestArgs, ->( +export function useCachedQuery>( query: Query, - ...args: Args extends undefined ? [] : [Args[0], CachedQueryOptions?] + queryArgs: FunctionArgs | (() => FunctionArgs), + options: CachedQueryOptions = {} ): QueryResult { - 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 }; - diff --git a/src/lib/spells/create-init.svelte.ts b/src/lib/spells/create-init.svelte.ts new file mode 100644 index 0000000..56d1f42 --- /dev/null +++ b/src/lib/spells/create-init.svelte.ts @@ -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 }; +} diff --git a/src/lib/spells/persisted-obj.svelte.ts b/src/lib/spells/persisted-obj.svelte.ts new file mode 100644 index 0000000..510ef76 --- /dev/null +++ b/src/lib/spells/persisted-obj.svelte.ts @@ -0,0 +1,118 @@ +import { on } from 'svelte/events'; +import { createSubscriber } from 'svelte/reactivity'; + +type Serializer = { + 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 = { + /** The storage type to use. Defaults to `local`. */ + storage?: StorageType; + /** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */ + serializer?: Serializer; + /** Whether to sync with the state changes from other tabs. Defaults to `true`. */ + syncTabs?: boolean; +}; + +export function createPersistedObj( + key: string, + initialValue: T, + options: PersistedObjOptions = {} +): 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); +} diff --git a/src/lib/state/models.svelte.ts b/src/lib/state/models.svelte.ts new file mode 100644 index 0000000..17c340c --- /dev/null +++ b/src/lib/state/models.svelte.ts @@ -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); + + 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

(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; + } +} + +export const models = new Models(); diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts new file mode 100644 index 0000000..5daa98d --- /dev/null +++ b/src/lib/state/settings.svelte.ts @@ -0,0 +1,5 @@ +import { createPersistedObj } from '$lib/spells/persisted-obj.svelte'; + +export const settings = createPersistedObj('settings', { + modelId: undefined as string | undefined, +}); diff --git a/src/lib/utils/array.ts b/src/lib/utils/array.ts index 9608d3f..ace8626 100644 --- a/src/lib/utils/array.ts +++ b/src/lib/utils/array.ts @@ -71,7 +71,7 @@ export function toMap( const map: Record = {}; 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; } diff --git a/src/lib/utils/is.ts b/src/lib/utils/is.ts new file mode 100644 index 0000000..408b9e6 --- /dev/null +++ b/src/lib/utils/is.ts @@ -0,0 +1,3 @@ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts index 2bdce8b..12c8749 100644 --- a/src/lib/utils/object.ts +++ b/src/lib/utils/object.ts @@ -3,6 +3,11 @@ export function keys(obj: T): Array { return Object.keys(obj) as Array; } +// typed object.entries +export function entries(obj: T): Array<[keyof T, T[keyof T]]> { + return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; +} + export function omit(obj: T, keys: K[]): Omit { return Object.fromEntries( Object.entries(obj).filter(([key]) => !keys.includes(key as K)) diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index d736029..f81ab66 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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[]), + }, }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 77bf533..d78b20e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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(); diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte index 4f1b70d..f48efe4 100644 --- a/src/routes/account/+layout.svelte +++ b/src/routes/account/+layout.svelte @@ -55,7 +55,7 @@ {data.session.user.name .split(' ') - .map((i) => i[0].toUpperCase()) + .map((i) => i[0]?.toUpperCase()) .join('')} {/snippet} diff --git a/src/routes/account/api-keys/provider-card.svelte b/src/routes/account/api-keys/provider-card.svelte index 1076cbd..d2396c8 100644 --- a/src/routes/account/api-keys/provider-card.svelte +++ b/src/routes/account/api-keys/provider-card.svelte @@ -21,7 +21,6 @@ const id = $props.id(); const keyQuery = useCachedQuery(api.user_keys.get, { - user_id: session.current?.user.id ?? '', provider, session_token: session.current?.session.token ?? '', }); diff --git a/src/routes/account/models/+page.server.ts b/src/routes/account/models/+page.server.ts deleted file mode 100644 index e16dd1a..0000000 --- a/src/routes/account/models/+page.server.ts +++ /dev/null @@ -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[]), - }; -} diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 0526eed..f0c2c6b 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -1,15 +1,7 @@ @@ -22,8 +14,7 @@

- {#each data.openRouterModels as model (model.id)} - {@const enabled = enabledModels.data?.[`${Provider.OpenRouter}:${model.id}`] !== undefined} - + {#each models.from(Provider.OpenRouter) as model (model.id)} + {/each}
diff --git a/src/routes/account/models/model-card.svelte b/src/routes/account/models/model-card.svelte index f8faa42..dc2ddf6 100644 --- a/src/routes/account/models/model-card.svelte +++ b/src/routes/account/models/model-card.svelte @@ -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( diff --git a/src/routes/api/generate-message/+server.ts b/src/routes/api/generate-message/+server.ts new file mode 100644 index 0000000..f50975c --- /dev/null +++ b/src/routes/api/generate-message/+server.ts @@ -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; + +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', + // }, + // }); +}; diff --git a/src/routes/api/generate-message/call.ts b/src/routes/api/generate-message/call.ts new file mode 100644 index 0000000..ae28391 --- /dev/null +++ b/src/routes/api/generate-message/call.ts @@ -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); + + return res; +} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index ed9a4ae..02aa4d9 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -1,12 +1,50 @@ @@ -19,19 +57,23 @@ Thom.chat -
- +
+ {#each conversationsQuery.data ?? [] as conversation (conversation._id)} + + {conversation.title} + + {/each}
{#if data.session !== null}