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 - [x] Actual fucking UI for chat
- [ ] Providers (BYOK) - [ ] Providers (BYOK)
- [x] Openrouter - [x] Openrouter
- [ ] HuggingFace - ~[ ] HuggingFace~
- [ ] OpenAI - ~[ ] OpenAI~
- [ ] File upload - [ ] File upload
- [x] Ensure responsiveness - [x] Ensure responsiveness
- [x] Streams on the server (Resumable streams) - [x] Streams on the server (Resumable streams)
@ -44,7 +44,7 @@ TODO: add instructions
- [ ] Error notification central, specially for BYOK models like o3 - [ ] Error notification central, specially for BYOK models like o3
- [ ] Google Auth - [ ] Google Auth
- [ ] Fix light mode (urgh) - [ ] Fix light mode (urgh)
- [ ] Streamer mode - [ ] Privacy mode
### Chat ### Chat

View file

@ -233,16 +233,14 @@
display: none; /* Chrome, Safari, and Opera */ display: none; /* Chrome, Safari, and Opera */
} }
@layer utilities { .animation-delay-0 {
.animation-delay-0 { animation-delay: 0s;
animation-delay: 0s; }
} .animation-delay-100 {
.animation-delay-100 { animation-delay: 0.1s;
animation-delay: 0.1s; }
} .animation-delay-200 {
.animation-delay-200 { animation-delay: 0.2s;
animation-delay: 0.2s;
}
} }
@layer components { @layer components {

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({ export const togglePin = mutation({
args: { args: {
conversation_id: v.id('conversations'), 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()), updated_at: v.optional(v.number()),
pinned: v.optional(v.boolean()), pinned: v.optional(v.boolean()),
generating: v.optional(v.boolean()), generating: v.optional(v.boolean()),
cost_usd: v.optional(v.number()),
}).index('by_user', ['user_id']), }).index('by_user', ['user_id']),
messages: defineTable({ messages: defineTable({
conversation_id: v.string(), 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']), }).index('by_conversation', ['conversation_id']),
}); });

View file

@ -64,7 +64,6 @@ export const get = query({
export const set = mutation({ export const set = mutation({
args: { args: {
provider: providerValidator, provider: providerValidator,
user_id: v.string(),
key: v.string(), key: v.string(),
session_token: v.string(), session_token: v.string(),
}, },
@ -80,14 +79,36 @@ export const set = mutation({
const existing = await ctx.db const existing = 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', args.user_id) q.eq('provider', args.provider).eq('user_id', session.userId)
) )
.first(); .first();
const userKey = { ...args, session_token: undefined, user_id: session.userId };
if (existing) { if (existing) {
await ctx.db.replace(existing._id, args); await ctx.db.replace(existing._id, userKey);
} else { } 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 { PUBLIC_CONVEX_URL } from '$env/static/public';
import { models } from '$lib/state/models.svelte'; import { models } from '$lib/state/models.svelte';
import GlobalModal from '$lib/components/ui/modal/global-modal.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(); let { children } = $props();
@ -12,6 +14,10 @@
models.init(); models.init();
</script> </script>
<svelte:window
use:shortcut={{ ctrl: true, shift: true, key: 'o', callback: () => goto('/chat') }}
/>
<ModeWatcher /> <ModeWatcher />
{@render children()} {@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() { async function signOut() {
await authClient.signOut(); await authClient.signOut();
@ -69,14 +85,17 @@
<div class="mt-4 flex w-full flex-col gap-2"> <div class="mt-4 flex w-full flex-col gap-2">
<span class="text-sm font-medium">Keyboard Shortcuts</span> <span class="text-sm font-medium">Keyboard Shortcuts</span>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex place-items-center justify-between"> {#each shortcuts as { name, keys } (name)}
<span class="text-muted-foreground text-sm">Toggle Sidebar </span> <div class="flex place-items-center justify-between">
<span class="text-muted-foreground text-sm">{name}</span>
<div> <div class="flex place-items-center gap-1">
<Kbd>{cmdOrCtrl}</Kbd> {#each keys as key (key)}
<Kbd>B</Kbd> <Kbd>{key}</Kbd>
{/each}
</div>
</div> </div>
</div> {/each}
</div> </div>
</div> </div>
</div> </div>

View file

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

View file

@ -42,7 +42,6 @@
const res = await ResultAsync.fromPromise( const res = await ResultAsync.fromPromise(
client.mutation(api.user_keys.set, { client.mutation(api.user_keys.set, {
provider, provider,
user_id: session.current?.user.id ?? '',
key: `${key}`, key: `${key}`,
session_token: session.current?.session.token, session_token: session.current?.session.token,
}), }),

View file

@ -31,7 +31,15 @@
}); });
const openRouterModels = $derived( 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> </script>

View file

@ -50,7 +50,10 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Card.Title>{model.name}</Card.Title> <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} /> <Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div> </div>
<Card.Description <Card.Description

View file

@ -1,16 +1,15 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel'; import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import type { SessionObj } from '$lib/backend/convex/betterAuth';
import { Provider } from '$lib/types'; import { Provider } from '$lib/types';
import { error, json, type RequestHandler } from '@sveltejs/kit'; import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import { ResultAsync } from 'neverthrow'; import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai'; import OpenAI from 'openai';
import { waitUntil } from '@vercel/functions'; import { waitUntil } from '@vercel/functions';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import type { ChatCompletionSystemMessageParam } from 'openai/resources'; import type { ChatCompletionSystemMessageParam } from 'openai/resources';
import { getSessionCookie } from 'better-auth/cookies';
// Set to true to enable debug logging // Set to true to enable debug logging
const ENABLE_LOGGING = true; const ENABLE_LOGGING = true;
@ -21,11 +20,15 @@ const reqBodySchema = z.object({
session_token: z.string(), session_token: z.string(),
conversation_id: z.string().optional(), conversation_id: z.string().optional(),
images: z.array(z.object({ images: z
url: z.string(), .array(
storage_id: z.string(), z.object({
fileName: z.string().optional(), url: z.string(),
})).optional(), storage_id: z.string(),
fileName: z.string().optional(),
})
)
.optional(),
}); });
export type GenerateMessageRequestBody = z.infer<typeof reqBodySchema>; export type GenerateMessageRequestBody = z.infer<typeof reqBodySchema>;
@ -49,13 +52,13 @@ const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
async function generateConversationTitle({ async function generateConversationTitle({
conversationId, conversationId,
session, sessionToken,
startTime, startTime,
keyResultPromise, keyResultPromise,
userMessage, userMessage,
}: { }: {
conversationId: string; conversationId: string;
session: SessionObj; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>; keyResultPromise: ResultAsync<string | null, string>;
userMessage: string; userMessage: string;
@ -78,7 +81,7 @@ async function generateConversationTitle({
// Only generate title if conversation currently has default title // Only generate title if conversation currently has default title
const conversationResult = await ResultAsync.fromPromise( const conversationResult = await ResultAsync.fromPromise(
client.query(api.conversations.get, { client.query(api.conversations.get, {
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to get conversations: ${e}` (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, { client.mutation(api.conversations.updateTitle, {
conversation_id: conversationId as Id<'conversations'>, conversation_id: conversationId as Id<'conversations'>,
title: generatedTitle, title: generatedTitle,
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to update conversation title: ${e}` (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({ async function generateAIResponse({
conversationId, conversationId,
session, sessionToken,
startTime, startTime,
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
rulesResultPromise, rulesResultPromise,
}: { }: {
conversationId: string; conversationId: string;
session: SessionObj; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>; keyResultPromise: ResultAsync<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>; modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
@ -175,7 +178,7 @@ async function generateAIResponse({
ResultAsync.fromPromise( ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, { client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>, conversation_id: conversationId as Id<'conversations'>,
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to get messages: ${e}` (e) => `Failed to get messages: ${e}`
), ),
@ -258,16 +261,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
role: 'user' as const, role: 'user' as const,
content: [ content: [
{ type: 'text' as const, text: m.content }, { type: 'text' as const, text: m.content },
...m.images.map(img => ({ ...m.images.map((img) => ({
type: 'image_url' as const, type: 'image_url' as const,
image_url: { url: img.url } image_url: { url: img.url },
})) })),
] ],
}; };
} }
return { return {
role: m.role as 'user' | 'assistant' | 'system', 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( const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, { client.mutation(api.messages.create, {
conversation_id: conversationId, conversation_id: conversationId,
model_id: model.model_id,
provider: Provider.OpenRouter,
content: '', content: '',
role: 'assistant', role: 'assistant',
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to create assistant message: ${e}` (e) => `Failed to create assistant message: ${e}`
); );
@ -310,6 +315,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
let content = ''; let content = '';
let chunkCount = 0; let chunkCount = 0;
let generationId: string | null = null;
try { try {
for await (const chunk of stream) { 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 || ''; content += chunk.choices[0]?.delta?.content || '';
if (!content) continue; if (!content) continue;
generationId = chunk.id;
const updateResult = await ResultAsync.fromPromise( const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, { client.mutation(api.messages.updateContent, {
message_id: mid, message_id: mid,
content, content,
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to update message content: ${e}` (e) => `Failed to update message content: ${e}`
); );
@ -339,14 +347,58 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
startTime startTime
); );
const updateGeneratingResult = await ResultAsync.fromPromise( if (!generationId) {
client.mutation(api.conversations.updateGenerating, { log('Background: No generation id found', startTime);
conversation_id: conversationId as Id<'conversations'>, return;
generating: false, }
session_token: session.token,
}), const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), {
(e) => `Failed to update generating status: ${e}` 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()) { if (updateGeneratingResult.isErr()) {
log(`Background generating status update failed: ${updateGeneratingResult.error}`, startTime); 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); 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) { } catch (error) {
log(`Background stream processing error: ${error}`, startTime); log(`Background stream processing error: ${error}`, startTime);
} }
@ -384,21 +450,12 @@ export const POST: RequestHandler = async ({ request }) => {
log('Schema validation passed', startTime); log('Schema validation passed', startTime);
const sessionResult = await ResultAsync.fromPromise( const cookie = getSessionCookie(request.headers);
client.query(api.betterAuth.publicGetSession, {
session_token: args.session_token,
}),
(e) => `Failed to get session: ${e}`
);
if (sessionResult.isErr()) { const sessionToken = cookie?.split('.')[0] ?? null;
log(`Session query failed: ${sessionResult.error}`, startTime);
return error(401, 'Failed to authenticate');
}
const session = sessionResult.value; if (!sessionToken) {
if (!session) { log(`No session token found`, startTime);
log('No session found - unauthorized', startTime);
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
@ -406,7 +463,7 @@ export const POST: RequestHandler = async ({ request }) => {
client.query(api.user_enabled_models.get, { client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter, provider: Provider.OpenRouter,
model_id: args.model_id, model_id: args.model_id,
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to get model: ${e}` (e) => `Failed to get model: ${e}`
); );
@ -414,14 +471,14 @@ export const POST: RequestHandler = async ({ request }) => {
const keyResultPromise = ResultAsync.fromPromise( const keyResultPromise = ResultAsync.fromPromise(
client.query(api.user_keys.get, { client.query(api.user_keys.get, {
provider: Provider.OpenRouter, provider: Provider.OpenRouter,
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to get API key: ${e}` (e) => `Failed to get API key: ${e}`
); );
const rulesResultPromise = ResultAsync.fromPromise( const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, { client.query(api.user_rules.all, {
session_token: session.token, session_token: sessionToken,
}), }),
(e) => `Failed to get rules: ${e}` (e) => `Failed to get rules: ${e}`
); );
@ -434,8 +491,8 @@ export const POST: RequestHandler = async ({ request }) => {
client.mutation(api.conversations.createAndAddMessage, { client.mutation(api.conversations.createAndAddMessage, {
content: args.message, content: args.message,
role: 'user', role: 'user',
session_token: session.token,
images: args.images, images: args.images,
session_token: sessionToken,
}), }),
(e) => `Failed to create conversation: ${e}` (e) => `Failed to create conversation: ${e}`
); );
@ -452,7 +509,7 @@ export const POST: RequestHandler = async ({ request }) => {
waitUntil( waitUntil(
generateConversationTitle({ generateConversationTitle({
conversationId, conversationId,
session, sessionToken,
startTime, startTime,
keyResultPromise, keyResultPromise,
userMessage: args.message, userMessage: args.message,
@ -486,7 +543,7 @@ export const POST: RequestHandler = async ({ request }) => {
waitUntil( waitUntil(
generateAIResponse({ generateAIResponse({
conversationId, conversationId,
session, sessionToken,
startTime, startTime,
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
@ -512,3 +569,89 @@ function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc<
return matchedRules; 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 UploadIcon from '~icons/lucide/upload';
import { callGenerateMessage } from '../api/generate-message/call.js'; import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte'; import ModelPicker from './model-picker.svelte';
import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js';
import { Provider } from '$lib/types.js';
const client = useConvexClient(); const client = useConvexClient();
@ -77,6 +79,11 @@
session_token: session.current?.session.token ?? '', 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, { const rulesQuery = useCachedQuery(api.user_rules.all, {
session_token: session.current?.session.token ?? '', session_token: session.current?.session.token ?? '',
}); });
@ -402,13 +409,19 @@
<span class="text-center font-serif text-lg">Thom.chat</span> <span class="text-center font-serif text-lg">Thom.chat</span>
</div> </div>
<div class="mt-1 flex w-full px-2"> <div class="mt-1 flex w-full px-2">
<a <Tooltip>
href="/chat" {#snippet trigger(tooltip)}
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" <a
style="font-variation-settings: 'wght' 750" 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"
New Chat {...tooltip.trigger}
</a> style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
{/snippet}
{cmdOrCtrl} + Shift + O
</Tooltip>
</div> </div>
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip"> <div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
<div <div
@ -529,9 +542,14 @@
</Sidebar.Sidebar> </Sidebar.Sidebar>
<Sidebar.Inset class="w-full overflow-clip "> <Sidebar.Inset class="w-full overflow-clip ">
<Sidebar.Trigger class="fixed top-3 left-2 z-50"> <Tooltip>
<PanelLeftIcon /> {#snippet trigger(tooltip)}
</Sidebar.Trigger> <Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
<PanelLeftIcon />
</Sidebar.Trigger>
{/snippet}
{cmdOrCtrl} + B
</Tooltip>
<!-- header --> <!-- header -->
<div class="bg-sidebar fixed top-0 right-0 z-50 hidden rounded-bl-lg p-1 md:flex"> <div class="bg-sidebar fixed top-0 right-0 z-50 hidden rounded-bl-lg p-1 md:flex">
@ -653,8 +671,9 @@
<textarea <textarea
{...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])} {...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])}
bind:this={textarea} 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" disabled={!openRouterKeyQuery.data}
placeholder="Type your message here..." 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" name="message"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !popover.open) { if (e.key === 'Enter' && !e.shiftKey && !popover.open) {

View file

@ -6,7 +6,10 @@
import NewspaperIcon from '~icons/lucide/newspaper'; import NewspaperIcon from '~icons/lucide/newspaper';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { usePrompt } from '$lib/state/prompt.svelte'; 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 = [ const defaultSuggestions = [
'Give me bad medical advice, doctor.', 'Give me bad medical advice, doctor.',
@ -56,11 +59,16 @@
let selectedCategory = $state<string | null>(null); 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(); const prompt = usePrompt();
</script> </script>
<div class="flex h-svh flex-col items-center justify-center"> <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 }}> <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> <h2 class="text-left font-serif text-3xl font-semibold">Hey there, Bozo!</h2>
<p class="mt-2 text-left text-lg"> <p class="mt-2 text-left text-lg">
@ -118,5 +126,15 @@
{/if} {/if}
</div> </div>
</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} {/if}
</div> </div>

View file

@ -78,11 +78,22 @@
</svelte:boundary> </svelte:boundary>
</div> </div>
<div <div
class={cn('flex place-items-center opacity-0 transition-opacity group-hover:opacity-100', { class={cn(
'justify-end': message.role === 'user', '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} /> <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>
</div> </div>