Merge branch 'main' into file-upload
This commit is contained in:
commit
420063f215
17 changed files with 416 additions and 96 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
18
src/app.css
18
src/app.css
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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']),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
11
src/routes/chat/+layout.server.ts
Normal file
11
src/routes/chat/+layout.server.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue