feat: Message error handling (#21)

This commit is contained in:
Aidan Bleser 2025-06-18 10:28:47 -05:00 committed by GitHub
parent d82ac749e1
commit 954b7173f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 66 deletions

View file

@ -92,6 +92,7 @@ export const createAndAddMessage = mutation({
content_html: v.optional(v.string()),
role: messageRoleValidator,
session_token: v.string(),
web_search_enabled: v.optional(v.boolean()),
images: v.optional(
v.array(
v.object({
@ -101,7 +102,6 @@ export const createAndAddMessage = mutation({
})
)
),
web_search_enabled: v.optional(v.boolean()),
},
handler: async (
ctx,

View file

@ -160,3 +160,36 @@ export const updateMessage = mutation({
});
},
});
export const updateError = mutation({
args: {
session_token: v.string(),
// optional in case the message hasn't been created yet
message_id: v.optional(v.string()),
conversation_id: v.string(),
error: 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');
}
await Promise.all([
args.message_id
? ctx.db.patch(args.message_id as Id<'messages'>, {
error: args.error,
})
: Promise.resolve(),
// reset loading state
ctx.db.patch(args.conversation_id as Id<'conversations'>, {
generating: false,
updated_at: Date.now(),
}),
]);
},
});

View file

@ -54,6 +54,7 @@ export default defineSchema({
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(),
content_html: v.optional(v.string()),
error: v.optional(v.string()),
// Optional, coming from SK API route
model_id: v.optional(v.string()),
provider: v.optional(providerValidator),

View file

@ -195,48 +195,124 @@ async function generateAIResponse({
]);
if (modelResult.isErr()) {
log(`Background model query failed: ${modelResult.error}`, startTime);
handleGenerationError({
error: modelResult.error,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const model = modelResult.value;
if (!model) {
log('Background: Model not found or not enabled', startTime);
handleGenerationError({
error: 'Model not found or not enabled',
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
log('Background: Model found and enabled', startTime);
if (messagesQueryResult.isErr()) {
log(`Background messages query failed: ${messagesQueryResult.error}`, startTime);
handleGenerationError({
error: `messages query failed: ${messagesQueryResult.error}`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const messages = messagesQueryResult.value;
log(`Background: Retrieved ${messages.length} messages from conversation`, startTime);
// Check if web search is enabled for the last user message
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id;
// Create assistant message
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: sessionToken,
web_search_enabled: webSearchEnabled,
}),
(e) => `Failed to create assistant message: ${e}`
);
if (messageCreationResult.isErr()) {
handleGenerationError({
error: `assistant message creation failed: ${messageCreationResult.error}`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime);
if (keyResult.isErr()) {
log(`Background API key query failed: ${keyResult.error}`, startTime);
handleGenerationError({
error: `API key query failed: ${keyResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
const key = keyResult.value;
if (!key) {
log('Background: No API key found', startTime);
handleGenerationError({
error: 'No API key found',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
log('Background: API key retrieved successfully', startTime);
if (rulesResult.isErr()) {
log(`Background rules query failed: ${rulesResult.error}`, startTime);
handleGenerationError({
error: `rules query failed: ${rulesResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
const userMessage = messages[messages.length - 1];
if (!userMessage) {
log('Background: No user message found', startTime);
handleGenerationError({
error: 'No user message found',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
@ -289,16 +365,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
: formattedMessages;
if (abortSignal?.aborted) {
log('AI response generation aborted before OpenAI call', startTime);
handleGenerationError({
error: 'Cancelled by user',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
// Check if web search is enabled for the last user message
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id;
const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create(
{
@ -315,35 +391,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
);
if (streamResult.isErr()) {
log(`Background OpenAI stream creation failed: ${streamResult.error}`, startTime);
handleGenerationError({
error: `Failed to create stream: ${streamResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime);
// Create assistant message
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: sessionToken,
web_search_enabled: webSearchEnabled,
}),
(e) => `Failed to create assistant message: ${e}`
);
if (messageCreationResult.isErr()) {
log(`Background assistant message creation failed: ${messageCreationResult.error}`, startTime);
return;
}
const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime);
let content = '';
let chunkCount = 0;
let generationId: string | null = null;
@ -469,7 +529,13 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
log('Background: Cost usd updated', startTime);
} catch (error) {
log(`Background stream processing error: ${error}`, startTime);
handleGenerationError({
error: `Stream processing error: ${error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
} finally {
// Clean up the cached AbortController
generationAbortControllers.delete(conversationId);
@ -711,6 +777,39 @@ async function retryResult<T, E>(
return lastResult;
}
async function handleGenerationError({
error,
conversationId,
messageId,
sessionToken,
startTime,
}: {
error: string;
conversationId: string;
messageId: string | undefined;
sessionToken: string;
startTime: number;
}) {
log(`Background: ${error}`, startTime);
const updateErrorResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateError, {
conversation_id: conversationId as Id<'conversations'>,
message_id: messageId,
error,
session_token: sessionToken,
}),
(e) => `Error updating error: ${e}`
);
if (updateErrorResult.isErr()) {
log(`Error updating error: ${updateErrorResult.error}`, startTime);
return;
}
log('Error updated', startTime);
}
export interface ApiResponse {
data: Data;
}

View file

@ -18,7 +18,6 @@
import { settings } from '$lib/state/settings.svelte.js';
import { Provider } from '$lib/types';
import { compressImage } from '$lib/utils/image-compression';
import { isString } from '$lib/utils/is.js';
import { supportsImages } from '$lib/utils/model-capabilities';
import { omit, pick } from '$lib/utils/object.js';
import { useConvexClient } from 'convex-svelte';
@ -52,7 +51,9 @@
session_token: session.current?.session.token ?? '',
}));
const isGenerating = $derived(Boolean(currentConversationQuery.data?.generating));
const isGenerating = $derived(
Boolean(currentConversationQuery.data?.generating) || currentConversationQuery.isLoading
);
async function stopGeneration() {
if (!page.params.id || !session.current?.session.token) return;
@ -78,23 +79,24 @@
}
}
let loading = $state(false);
const textareaDisabled = $derived(isGenerating || loading);
async function handleSubmit() {
if (isGenerating) return;
const formData = new FormData(form);
const message = formData.get('message');
// TODO: Re-use zod here from server endpoint for better error messages?
if (!isString(message) || !session.current?.user.id || !settings.modelId) return;
if (message.current === '' || !session.current?.user.id || !settings.modelId) return;
loading = true;
if (textarea) textarea.value = '';
const messageCopy = message;
const imagesCopy = [...selectedImages];
selectedImages = [];
try {
const res = await callGenerateMessage({
message: messageCopy,
message: message.current,
session_token: session.current?.session.token,
conversation_id: page.params.id ?? undefined,
model_id: settings.modelId,
@ -113,14 +115,12 @@
}
} catch (error) {
console.error('Error generating message:', error);
} finally {
loading = false;
message.current = '';
}
}
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 ?? '',
});
@ -383,8 +383,10 @@
<div class="relative">
<div bind:this={conversationList} class="h-screen overflow-y-auto">
<div
class="mx-auto flex max-w-3xl flex-col"
style="padding-bottom: {page.url.pathname !== '/chat' ? wrapperSize.height : 0}px"
class={cn('mx-auto flex max-w-3xl flex-col', {
'pt-10': page.url.pathname !== '/chat',
})}
style="padding-bottom: {page.url.pathname !== '/chat' ? wrapperSize.height : 0}px;"
>
{@render children()}
</div>
@ -497,7 +499,7 @@
<textarea
{...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])}
bind:this={textarea}
disabled={!openRouterKeyQuery.data || isGenerating}
disabled={textareaDisabled}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[80px] w-full resize-none !overflow-y-auto bg-transparent px-3 text-base leading-6 outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder={isGenerating
? 'Generating response...'
@ -547,7 +549,7 @@
type={isGenerating ? 'button' : 'submit'}
onclick={isGenerating ? stopGeneration : undefined}
disabled={isGenerating ? false : !message.current.trim()}
class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50"
class="border-reflect button-reflect hover:bg-primary/90 active:bg-primary text-foreground dark:text-primary-foreground relative h-9 w-9 rounded-lg p-2 font-semibold shadow transition disabled:cursor-not-allowed disabled:opacity-50"
{...tooltip.trigger}
>
{#if isGenerating}

View file

@ -39,13 +39,8 @@
}
</script>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0)}
<div
class={cn('group flex flex-col gap-1', {
'self-end': message.role === 'user',
'max-w-[80%]': message.role === 'user',
})}
>
{#if message.role !== 'system' && !(message.role === 'assistant' && message.content.length === 0 && !message.error)}
<div class={cn('group flex max-w-[80%] flex-col gap-1', { 'self-end': message.role === 'user' })}>
{#if message.images && message.images.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each message.images as image (image.storage_id)}
@ -64,7 +59,11 @@
</div>
{/if}
<div class={style({ role: message.role })}>
{#if message.content_html}
{#if message.error}
<div class="text-destructive">
<pre class="!bg-sidebar"><code>{message.error}</code></pre>
</div>
{:else if message.content_html}
{@html sanitizeHtml(message.content_html)}
{:else}
<svelte:boundary>
@ -89,7 +88,9 @@
}
)}
>
<CopyButton class="size-7" text={message.content} />
{#if message.content.length > 0}
<CopyButton class="size-7" text={message.content} />
{/if}
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}