Merge branch 'main' into file-upload

This commit is contained in:
Thomas G. Lopes 2025-06-17 23:06:09 +01:00
commit 420063f215
17 changed files with 416 additions and 96 deletions

View file

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

View file

@ -233,7 +233,6 @@
display: none; /* Chrome, Safari, and Opera */
}
@layer utilities {
.animation-delay-0 {
animation-delay: 0s;
}
@ -243,7 +242,6 @@
.animation-delay-200 {
animation-delay: 0.2s;
}
}
@layer components {
/* Modal is from DaisyUI */

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
</script>
<svelte:window
use:shortcut={{ ctrl: true, shift: true, key: 'o', callback: () => goto('/chat') }}
/>
<ModeWatcher />
{@render children()}

View file

@ -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 @@
<div class="mt-4 flex w-full flex-col gap-2">
<span class="text-sm font-medium">Keyboard Shortcuts</span>
<div class="flex flex-col gap-1">
{#each shortcuts as { name, keys } (name)}
<div class="flex place-items-center justify-between">
<span class="text-muted-foreground text-sm">Toggle Sidebar </span>
<span class="text-muted-foreground text-sm">{name}</span>
<div>
<Kbd>{cmdOrCtrl}</Kbd>
<Kbd>B</Kbd>
<div class="flex place-items-center gap-1">
{#each keys as key (key)}
<Kbd>{key}</Kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>

View file

@ -56,7 +56,10 @@
<div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)}
<!-- only do OpenRouter for now -->
{#if provider === Provider.OpenRouter}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
{/if}
{/each}
</div>

View file

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

View file

@ -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;
})
);
</script>

View file

@ -50,7 +50,10 @@
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title>
<span class="text-muted-foreground text-xs hidden xl:block">{model.id}</span>
</div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div>
<Card.Description

View file

@ -1,16 +1,15 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import type { SessionObj } from '$lib/backend/convex/betterAuth';
import { Provider } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ConvexHttpClient } from 'convex/browser';
import { ResultAsync } from 'neverthrow';
import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { waitUntil } from '@vercel/functions';
import { z } from 'zod/v4';
import type { ChatCompletionSystemMessageParam } from 'openai/resources';
import { getSessionCookie } from 'better-auth/cookies';
// Set to true to enable debug logging
const ENABLE_LOGGING = true;
@ -21,11 +20,15 @@ const reqBodySchema = z.object({
session_token: z.string(),
conversation_id: z.string().optional(),
images: z.array(z.object({
images: z
.array(
z.object({
url: z.string(),
storage_id: z.string(),
fileName: z.string().optional(),
})).optional(),
})
)
.optional(),
});
export type GenerateMessageRequestBody = z.infer<typeof reqBodySchema>;
@ -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<string | null, string>;
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<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | 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
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(
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: session.token,
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<Result<Data, string>> {
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<T, E>(
fn: () => Promise<Result<T, E>>,
{
retries,
delay,
startTime,
fnName,
}: { retries: number; delay: number; startTime: number; fnName: string }
): Promise<Result<T, E>> {
let attempts = 0;
let lastResult: Result<T, E> | 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;
}

View file

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

View file

@ -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 @@
<span class="text-center font-serif text-lg">Thom.chat</span>
</div>
<div class="mt-1 flex w-full px-2">
<Tooltip>
{#snippet trigger(tooltip)}
<a
href="/chat"
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
{...tooltip.trigger}
style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
{/snippet}
{cmdOrCtrl} + Shift + O
</Tooltip>
</div>
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
<div
@ -529,9 +542,14 @@
</Sidebar.Sidebar>
<Sidebar.Inset class="w-full overflow-clip ">
<Sidebar.Trigger class="fixed top-3 left-2 z-50">
<Tooltip>
{#snippet trigger(tooltip)}
<Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
<PanelLeftIcon />
</Sidebar.Trigger>
{/snippet}
{cmdOrCtrl} + B
</Tooltip>
<!-- header -->
<div class="bg-sidebar fixed top-0 right-0 z-50 hidden rounded-bl-lg p-1 md:flex">
@ -653,8 +671,9 @@
<textarea
{...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])}
bind:this={textarea}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[60px] w-full resize-none !overflow-y-auto bg-transparent text-base leading-6 outline-none disabled:opacity-0"
placeholder="Type your message here..."
disabled={!openRouterKeyQuery.data}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[60px] w-full resize-none !overflow-y-auto bg-transparent text-base leading-6 outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Type your message here... Tag rules with @"
name="message"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !popover.open) {

View file

@ -6,7 +6,10 @@
import NewspaperIcon from '~icons/lucide/newspaper';
import { Button } from '$lib/components/ui/button';
import { usePrompt } from '$lib/state/prompt.svelte';
import { fade, scale } from 'svelte/transition';
import { scale } from 'svelte/transition';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { Provider } from '$lib/types';
const defaultSuggestions = [
'Give me bad medical advice, doctor.',
@ -56,11 +59,16 @@
let selectedCategory = $state<string | null>(null);
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '',
});
const prompt = usePrompt();
</script>
<div class="flex h-svh flex-col items-center justify-center">
{#if prompt.current.length === 0}
{#if prompt.current.length === 0 && openRouterKeyQuery.data}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">Hey there, Bozo!</h2>
<p class="mt-2 text-left text-lg">
@ -118,5 +126,15 @@
{/if}
</div>
</div>
{:else if !openRouterKeyQuery.data}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there, {session.current?.user.name}!
</h2>
<p class="mt-2 text-left text-lg">
You need to provide a key to start sending messages.
<a href="/account/api-keys" class="text-primary"> Go to settings </a>
</p>
</div>
{/if}
</div>

View file

@ -78,11 +78,22 @@
</svelte:boundary>
</div>
<div
class={cn('flex place-items-center opacity-0 transition-opacity group-hover:opacity-100', {
class={cn(
'flex place-items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100',
{
'justify-end': message.role === 'user',
})}
}
)}
>
<CopyButton class="size-7" text={message.content} />
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}
{#if message.cost_usd !== undefined}
<span class="text-muted-foreground text-xs">
${message.cost_usd.toFixed(6)}
</span>
{/if}
</div>
</div>