diff --git a/README.md b/README.md index f6282d0..70bde96 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ TODO: add instructions - [x] Actual fucking UI for chat - [ ] Providers (BYOK) - [x] Openrouter - - [ ] HuggingFace - - [ ] OpenAI + - ~[ ] HuggingFace~ + - ~[ ] OpenAI~ - [ ] File upload - [x] Ensure responsiveness - [x] Streams on the server (Resumable streams) @@ -44,7 +44,7 @@ TODO: add instructions - [ ] Error notification central, specially for BYOK models like o3 - [ ] Google Auth - [ ] Fix light mode (urgh) -- [ ] Streamer mode +- [ ] Privacy mode ### Chat diff --git a/src/app.css b/src/app.css index 40442ae..b7d4706 100644 --- a/src/app.css +++ b/src/app.css @@ -233,16 +233,14 @@ display: none; /* Chrome, Safari, and Opera */ } -@layer utilities { - .animation-delay-0 { - animation-delay: 0s; - } - .animation-delay-100 { - animation-delay: 0.1s; - } - .animation-delay-200 { - animation-delay: 0.2s; - } +.animation-delay-0 { + animation-delay: 0s; +} +.animation-delay-100 { + animation-delay: 0.1s; +} +.animation-delay-200 { + animation-delay: 0.2s; } @layer components { diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index 3e1105a..aaf2b78 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -189,6 +189,33 @@ export const updateGenerating = mutation({ }, }); +export const updateCostUsd = mutation({ + args: { + conversation_id: v.id('conversations'), + cost_usd: v.number(), + 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'); + } + + // Verify the conversation belongs to the user + const conversation = await ctx.db.get(args.conversation_id); + if (!conversation || conversation.user_id !== session.userId) { + throw new Error('Conversation not found or unauthorized'); + } + + await ctx.db.patch(args.conversation_id, { + cost_usd: (conversation.cost_usd ?? 0) + args.cost_usd, + }); + }, +}); + export const togglePin = mutation({ args: { conversation_id: v.id('conversations'), diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 5646856..3df1f1f 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -117,3 +117,34 @@ export const updateContent = mutation({ }); }, }); + +export const updateMessage = mutation({ + args: { + session_token: v.string(), + message_id: v.string(), + token_count: v.optional(v.number()), + cost_usd: v.optional(v.number()), + generation_id: v.optional(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, { + token_count: args.token_count, + cost_usd: args.cost_usd, + generation_id: args.generation_id, + }); + }, +}); diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index 16832c6..3304de7 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -47,6 +47,7 @@ export default defineSchema({ updated_at: v.optional(v.number()), pinned: v.optional(v.boolean()), generating: v.optional(v.boolean()), + cost_usd: v.optional(v.number()), }).index('by_user', ['user_id']), messages: defineTable({ conversation_id: v.string(), @@ -66,5 +67,7 @@ export default defineSchema({ }) ) ), + cost_usd: v.optional(v.number()), + generation_id: v.optional(v.string()), }).index('by_conversation', ['conversation_id']), }); diff --git a/src/lib/backend/convex/user_keys.ts b/src/lib/backend/convex/user_keys.ts index ebcdada..a91a1c9 100644 --- a/src/lib/backend/convex/user_keys.ts +++ b/src/lib/backend/convex/user_keys.ts @@ -64,7 +64,6 @@ export const get = query({ export const set = mutation({ args: { provider: providerValidator, - user_id: v.string(), key: v.string(), session_token: v.string(), }, @@ -80,14 +79,36 @@ export const set = mutation({ const existing = await ctx.db .query('user_keys') .withIndex('by_provider_user', (q) => - q.eq('provider', args.provider).eq('user_id', args.user_id) + q.eq('provider', args.provider).eq('user_id', session.userId) ) .first(); + const userKey = { ...args, session_token: undefined, user_id: session.userId }; + if (existing) { - await ctx.db.replace(existing._id, args); + await ctx.db.replace(existing._id, userKey); } else { - await ctx.db.insert('user_keys', args); + await ctx.db.insert('user_keys', userKey); + + if (args.provider === Provider.OpenRouter) { + const defaultModels = [ + 'google/gemini-2.5-flash', + 'anthropic/claude-sonnet-4', + 'openai/o3-mini', + 'deepseek/deepseek-chat-v3-0324:free', + ]; + + await Promise.all( + defaultModels.map((model) => + ctx.db.insert('user_enabled_models', { + user_id: session.userId, + provider: Provider.OpenRouter, + model_id: model, + pinned: null, + }) + ) + ); + } } }, }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ee0dbaa..e4df54b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { models } from '$lib/state/models.svelte'; import GlobalModal from '$lib/components/ui/modal/global-modal.svelte'; + import { shortcut } from '$lib/actions/shortcut.svelte'; + import { goto } from '$app/navigation'; let { children } = $props(); @@ -12,6 +14,10 @@ models.init(); + goto('/chat') }} +/> + {@render children()} diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte index d937661..10eb590 100644 --- a/src/routes/account/+layout.svelte +++ b/src/routes/account/+layout.svelte @@ -30,6 +30,22 @@ }, ]; + type Shortcut = { + name: string; + keys: string[]; + }; + + const shortcuts: Shortcut[] = [ + { + name: 'Toggle Sidebar', + keys: [cmdOrCtrl, 'B'], + }, + { + name: 'New Chat', + keys: [cmdOrCtrl, 'Shift', 'O'], + }, + ]; + async function signOut() { await authClient.signOut(); @@ -69,14 +85,17 @@
Keyboard Shortcuts
-
- Toggle Sidebar + {#each shortcuts as { name, keys } (name)} +
+ {name} -
- {cmdOrCtrl} - B +
+ {#each keys as key (key)} + {key} + {/each} +
-
+ {/each}
diff --git a/src/routes/account/api-keys/+page.svelte b/src/routes/account/api-keys/+page.svelte index 037f394..fc32ccf 100644 --- a/src/routes/account/api-keys/+page.svelte +++ b/src/routes/account/api-keys/+page.svelte @@ -56,7 +56,10 @@
{#each allProviders as provider (provider)} - {@const meta = providersMeta[provider]} - + + {#if provider === Provider.OpenRouter} + {@const meta = providersMeta[provider]} + + {/if} {/each}
diff --git a/src/routes/account/api-keys/provider-card.svelte b/src/routes/account/api-keys/provider-card.svelte index aaf412e..a6242a9 100644 --- a/src/routes/account/api-keys/provider-card.svelte +++ b/src/routes/account/api-keys/provider-card.svelte @@ -42,7 +42,6 @@ const res = await ResultAsync.fromPromise( client.mutation(api.user_keys.set, { provider, - user_id: session.current?.user.id ?? '', key: `${key}`, session_token: session.current?.session.token, }), diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 003c1ec..768e399 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -31,7 +31,15 @@ }); const openRouterModels = $derived( - fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' }) + fuzzysearch({ + haystack: models.from(Provider.OpenRouter), + needle: search, + property: 'name', + }).sort((a, b) => { + if (a.enabled && !b.enabled) return -1; + if (!a.enabled && b.enabled) return 1; + return 0; + }) ); diff --git a/src/routes/account/models/model-card.svelte b/src/routes/account/models/model-card.svelte index 7b995c4..41adea5 100644 --- a/src/routes/account/models/model-card.svelte +++ b/src/routes/account/models/model-card.svelte @@ -50,7 +50,10 @@
- {model.name} +
+ {model.name} + +
enabled, toggleEnabled} {disabled} />
; @@ -49,13 +52,13 @@ const client = new ConvexHttpClient(PUBLIC_CONVEX_URL); async function generateConversationTitle({ conversationId, - session, + sessionToken, startTime, keyResultPromise, userMessage, }: { conversationId: string; - session: SessionObj; + sessionToken: string; startTime: number; keyResultPromise: ResultAsync; userMessage: string; @@ -78,7 +81,7 @@ async function generateConversationTitle({ // Only generate title if conversation currently has default title const conversationResult = await ResultAsync.fromPromise( client.query(api.conversations.get, { - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get conversations: ${e}` ); @@ -139,7 +142,7 @@ Generate only the title based on what the user is asking for, nothing else:`; client.mutation(api.conversations.updateTitle, { conversation_id: conversationId as Id<'conversations'>, title: generatedTitle, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to update conversation title: ${e}` ); @@ -154,14 +157,14 @@ Generate only the title based on what the user is asking for, nothing else:`; async function generateAIResponse({ conversationId, - session, + sessionToken, startTime, modelResultPromise, keyResultPromise, rulesResultPromise, }: { conversationId: string; - session: SessionObj; + sessionToken: string; startTime: number; keyResultPromise: ResultAsync; modelResultPromise: ResultAsync | null, string>; @@ -175,7 +178,7 @@ async function generateAIResponse({ ResultAsync.fromPromise( client.query(api.messages.getAllFromConversation, { conversation_id: conversationId as Id<'conversations'>, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get messages: ${e}` ), @@ -258,16 +261,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, role: 'user' as const, content: [ { type: 'text' as const, text: m.content }, - ...m.images.map(img => ({ + ...m.images.map((img) => ({ type: 'image_url' as const, - image_url: { url: img.url } - })) - ] + image_url: { url: img.url }, + })), + ], }; } - return { - role: m.role as 'user' | 'assistant' | 'system', - content: m.content + return { + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, }; }); @@ -293,9 +296,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, const messageCreationResult = await ResultAsync.fromPromise( client.mutation(api.messages.create, { conversation_id: conversationId, + model_id: model.model_id, + provider: Provider.OpenRouter, content: '', role: 'assistant', - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to create assistant message: ${e}` ); @@ -310,6 +315,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, let content = ''; let chunkCount = 0; + let generationId: string | null = null; try { for await (const chunk of stream) { @@ -317,11 +323,13 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, content += chunk.choices[0]?.delta?.content || ''; if (!content) continue; + generationId = chunk.id; + const updateResult = await ResultAsync.fromPromise( client.mutation(api.messages.updateContent, { message_id: mid, content, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to update message content: ${e}` ); @@ -339,14 +347,58 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, startTime ); - const updateGeneratingResult = await ResultAsync.fromPromise( - client.mutation(api.conversations.updateGenerating, { - conversation_id: conversationId as Id<'conversations'>, - generating: false, - session_token: session.token, - }), - (e) => `Failed to update generating status: ${e}` - ); + if (!generationId) { + log('Background: No generation id found', startTime); + return; + } + + const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), { + delay: 500, + retries: 2, + startTime, + fnName: 'getGenerationStats', + }); + + if (generationStatsResult.isErr()) { + log(`Background: Failed to get generation stats: ${generationStatsResult.error}`, startTime); + } + + // just default so we don't blow up + const generationStats = generationStatsResult.unwrapOr({ + tokens_completion: undefined, + total_cost: undefined, + }); + + log('Background: Got generation stats', startTime); + + const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([ + ResultAsync.fromPromise( + client.mutation(api.messages.updateMessage, { + message_id: mid, + token_count: generationStats.tokens_completion, + cost_usd: generationStats.total_cost, + generation_id: generationId, + session_token: sessionToken, + }), + (e) => `Failed to update message: ${e}` + ), + ResultAsync.fromPromise( + client.mutation(api.conversations.updateGenerating, { + conversation_id: conversationId as Id<'conversations'>, + generating: false, + session_token: sessionToken, + }), + (e) => `Failed to update generating status: ${e}` + ), + ResultAsync.fromPromise( + client.mutation(api.conversations.updateCostUsd, { + conversation_id: conversationId as Id<'conversations'>, + cost_usd: generationStats.total_cost ?? 0, + session_token: sessionToken, + }), + (e) => `Failed to update cost usd: ${e}` + ), + ]); if (updateGeneratingResult.isErr()) { log(`Background generating status update failed: ${updateGeneratingResult.error}`, startTime); @@ -354,6 +406,20 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, } log('Background: Generating status updated to false', startTime); + + if (updateMessageResult.isErr()) { + log(`Background message update failed: ${updateMessageResult.error}`, startTime); + return; + } + + log('Background: Message updated', startTime); + + if (updateCostUsdResult.isErr()) { + log(`Background cost usd update failed: ${updateCostUsdResult.error}`, startTime); + return; + } + + log('Background: Cost usd updated', startTime); } catch (error) { log(`Background stream processing error: ${error}`, startTime); } @@ -384,21 +450,12 @@ export const POST: RequestHandler = async ({ request }) => { 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}` - ); + const cookie = getSessionCookie(request.headers); - if (sessionResult.isErr()) { - log(`Session query failed: ${sessionResult.error}`, startTime); - return error(401, 'Failed to authenticate'); - } + const sessionToken = cookie?.split('.')[0] ?? null; - const session = sessionResult.value; - if (!session) { - log('No session found - unauthorized', startTime); + if (!sessionToken) { + log(`No session token found`, startTime); return error(401, 'Unauthorized'); } @@ -406,7 +463,7 @@ export const POST: RequestHandler = async ({ request }) => { client.query(api.user_enabled_models.get, { provider: Provider.OpenRouter, model_id: args.model_id, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get model: ${e}` ); @@ -414,14 +471,14 @@ export const POST: RequestHandler = async ({ request }) => { const keyResultPromise = ResultAsync.fromPromise( client.query(api.user_keys.get, { provider: Provider.OpenRouter, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get API key: ${e}` ); const rulesResultPromise = ResultAsync.fromPromise( client.query(api.user_rules.all, { - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get rules: ${e}` ); @@ -434,8 +491,8 @@ export const POST: RequestHandler = async ({ request }) => { client.mutation(api.conversations.createAndAddMessage, { content: args.message, role: 'user', - session_token: session.token, images: args.images, + session_token: sessionToken, }), (e) => `Failed to create conversation: ${e}` ); @@ -452,7 +509,7 @@ export const POST: RequestHandler = async ({ request }) => { waitUntil( generateConversationTitle({ conversationId, - session, + sessionToken, startTime, keyResultPromise, userMessage: args.message, @@ -486,7 +543,7 @@ export const POST: RequestHandler = async ({ request }) => { waitUntil( generateAIResponse({ conversationId, - session, + sessionToken, startTime, modelResultPromise, keyResultPromise, @@ -512,3 +569,89 @@ function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc< return matchedRules; } + +async function getGenerationStats( + generationId: string, + token: string +): Promise> { + try { + const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const { data } = await generation.json(); + + if (!data) { + return err('No data returned from OpenRouter'); + } + + return ok(data); + } catch { + return err('Failed to get generation stats'); + } +} + +async function retryResult( + fn: () => Promise>, + { + retries, + delay, + startTime, + fnName, + }: { retries: number; delay: number; startTime: number; fnName: string } +): Promise> { + let attempts = 0; + let lastResult: Result | null = null; + + while (attempts <= retries) { + lastResult = await fn(); + + if (lastResult.isOk()) return lastResult; + + log(`Retrying ${fnName} ${attempts} failed: ${lastResult.error}`, startTime); + + await new Promise((resolve) => setTimeout(resolve, delay)); + attempts++; + } + + if (!lastResult) throw new Error('This should never happen'); + + return lastResult; +} + +export interface ApiResponse { + data: Data; +} + +export interface Data { + created_at: string; + model: string; + app_id: string | null; + external_user: string | null; + streamed: boolean; + cancelled: boolean; + latency: number; + moderation_latency: number | null; + generation_time: number; + tokens_prompt: number; + tokens_completion: number; + native_tokens_prompt: number; + native_tokens_completion: number; + native_tokens_reasoning: number; + native_tokens_cached: number; + num_media_prompt: number | null; + num_media_completion: number | null; + num_search_results: number | null; + origin: string; + is_byok: boolean; + finish_reason: string; + native_finish_reason: string; + usage: number; + id: string; + upstream_id: string; + total_cost: number; + cache_discount: number | null; + provider_name: string; +} diff --git a/src/routes/chat/+layout.server.ts b/src/routes/chat/+layout.server.ts new file mode 100644 index 0000000..f43adcc --- /dev/null +++ b/src/routes/chat/+layout.server.ts @@ -0,0 +1,11 @@ +import { redirectToLogin } from '$lib/backend/auth/redirect'; + +export async function load({ locals, url }) { + const session = await locals.auth(); + + if (!session) redirectToLogin(url); + + return { + session, + }; +} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 795be72..ea99540 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -38,6 +38,8 @@ import UploadIcon from '~icons/lucide/upload'; import { callGenerateMessage } from '../api/generate-message/call.js'; import ModelPicker from './model-picker.svelte'; + import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js'; + import { Provider } from '$lib/types.js'; const client = useConvexClient(); @@ -77,6 +79,11 @@ session_token: session.current?.session.token ?? '', }); + const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { + provider: Provider.OpenRouter, + session_token: session.current?.session.token ?? '', + }); + const rulesQuery = useCachedQuery(api.user_rules.all, { session_token: session.current?.session.token ?? '', }); @@ -402,13 +409,19 @@ Thom.chat
- - New Chat - + + {#snippet trigger(tooltip)} + + New Chat + + {/snippet} + {cmdOrCtrl} + Shift + O +
- - - + + {#snippet trigger(tooltip)} + + + + {/snippet} + {cmdOrCtrl} + B +