feat: Message error handling (#21)
This commit is contained in:
parent
d82ac749e1
commit
954b7173f7
6 changed files with 202 additions and 66 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue