From 558c035b845f17676e4eb61d16e47e1c370a862d Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 4 Jan 2026 01:46:26 +0100 Subject: [PATCH] fix: prevent stream abort and improve attachment handling - Limit max attachments to 5 files to prevent context overflow - Fix URL update timing: use SvelteKit's replaceState in onComplete callback instead of history.replaceState before streaming - Load attachment content from IndexedDB in conversation history so follow-up messages have access to file content - Show error messages in chat when Ollama fails instead of stuck "Processing..." indicator - Force file analysis when >3 files attached to reduce context usage --- .../src/lib/components/chat/ChatInput.svelte | 7 +- .../src/lib/components/chat/ChatWindow.svelte | 64 +++++++++++++++---- .../src/lib/components/chat/FileUpload.svelte | 13 +++- frontend/src/routes/+page.svelte | 24 +++++-- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/components/chat/ChatInput.svelte b/frontend/src/lib/components/chat/ChatInput.svelte index 0807517..df6a1d4 100644 --- a/frontend/src/lib/components/chat/ChatInput.svelte +++ b/frontend/src/lib/components/chat/ChatInput.svelte @@ -278,8 +278,13 @@ } } - // Process other files + // Process other files (limit to 5 total attachments) + const MAX_ATTACHMENTS = 5; for (const file of otherFiles) { + if (pendingAttachments.length >= MAX_ATTACHMENTS) { + console.warn(`Maximum ${MAX_ATTACHMENTS} files reached, skipping remaining files`); + break; + } const result = await processFile(file); if (result.success) { pendingAttachments = [...pendingAttachments, result.attachment]; diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index 3df5299..5367c43 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -12,6 +12,7 @@ import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation, saveAttachments } from '$lib/storage'; import type { FileAttachment } from '$lib/types/attachment.js'; import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js'; + import { attachmentService } from '$lib/services/attachmentService.js'; import { contextManager, generateSummary, @@ -223,13 +224,38 @@ /** * Convert chat state messages to Ollama API format * Uses messagesForContext to exclude summarized originals but include summaries + * Now includes attachment content loaded from IndexedDB */ - function getMessagesForApi(): OllamaMessage[] { - return chatState.messagesForContext.map((node) => ({ - role: node.message.role as OllamaMessage['role'], - content: node.message.content, - images: node.message.images - })); + async function getMessagesForApi(): Promise { + const messages: OllamaMessage[] = []; + + for (const node of chatState.messagesForContext) { + let content = node.message.content; + let images = node.message.images; + + // Load attachment content if present + if (node.message.attachmentIds && node.message.attachmentIds.length > 0) { + // Load text content from attachments + const attachmentContent = await attachmentService.buildOllamaContent(node.message.attachmentIds); + if (attachmentContent) { + content = content + '\n\n' + attachmentContent; + } + + // Load image base64 from attachments + const attachmentImages = await attachmentService.buildOllamaImages(node.message.attachmentIds); + if (attachmentImages.length > 0) { + images = [...(images || []), ...attachmentImages]; + } + } + + messages.push({ + role: node.message.role as OllamaMessage['role'], + content, + images + }); + } + + return messages; } /** @@ -495,13 +521,17 @@ try { // Check if any files need actual LLM analysis - const filesToAnalyze = attachments.filter(a => fileAnalyzer.shouldAnalyze(a)); + // Force analysis when >3 files to prevent context overflow (max 5 files allowed) + const forceAnalysis = attachments.length > 3; + const filesToAnalyze = forceAnalysis + ? attachments.filter(a => a.textContent && a.textContent.length > 2000) + : attachments.filter(a => fileAnalyzer.shouldAnalyze(a)); if (filesToAnalyze.length > 0) { // Update indicator to show analysis chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`); - const analysisResults = await analyzeFilesInBatches(filesToAnalyze, selectedModel, 2); + const analysisResults = await analyzeFilesInBatches(filesToAnalyze, selectedModel, 3); // Update attachments with results filesToAnalyze.forEach((file) => { @@ -527,7 +557,7 @@ contentForOllama = formattedParts.join('\n\n'); } else { - // No files need analysis, just format with content + // No files need analysis, format with content const parts: string[] = [content]; for (const a of attachments) { if (a.textContent) { @@ -587,7 +617,7 @@ let pendingToolCalls: OllamaToolCall[] | null = null; try { - let messages = getMessagesForApi(); + let messages = await getMessagesForApi(); const tools = getToolsForApi(); // If we have a content override (formatted attachments), replace the last user message content @@ -743,6 +773,9 @@ }, onError: (error) => { console.error('Streaming error:', error); + // Show error to user instead of leaving "Processing..." + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); chatState.finishStreaming(); streamingMetricsState.endStream(); abortController = null; @@ -751,6 +784,10 @@ abortController.signal ); } catch (error) { + console.error('Failed to send message:', error); + // Show error to user + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); toastState.error('Failed to send message. Please try again.'); chatState.finishStreaming(); streamingMetricsState.endStream(); @@ -899,7 +936,7 @@ try { // Get messages for API - excludes the current empty assistant message being streamed - const messages = getMessagesForApi().filter(m => m.content !== ''); + const messages = (await getMessagesForApi()).filter(m => m.content !== ''); const tools = getToolsForApi(); // Use function model for tool routing if enabled and tools are present @@ -956,6 +993,8 @@ }, onError: (error) => { console.error('Regenerate error:', error); + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); chatState.finishStreaming(); streamingMetricsState.endStream(); abortController = null; @@ -964,6 +1003,9 @@ abortController.signal ); } catch (error) { + console.error('Failed to regenerate:', error); + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); toastState.error('Failed to regenerate. Please try again.'); chatState.finishStreaming(); streamingMetricsState.endStream(); diff --git a/frontend/src/lib/components/chat/FileUpload.svelte b/frontend/src/lib/components/chat/FileUpload.svelte index b6f18a6..6c62d58 100644 --- a/frontend/src/lib/components/chat/FileUpload.svelte +++ b/frontend/src/lib/components/chat/FileUpload.svelte @@ -52,6 +52,9 @@ let errorMessage = $state(null); let fileInputRef: HTMLInputElement | null = $state(null); + // Constants + const MAX_ATTACHMENTS = 5; + // Derived states const hasAttachments = $derived(attachments.length > 0); const hasImages = $derived(images.length > 0); @@ -111,7 +114,15 @@ } if (newAttachments.length > 0) { - onAttachmentsChange([...attachments, ...newAttachments]); + const combined = [...attachments, ...newAttachments]; + if (combined.length > MAX_ATTACHMENTS) { + const kept = combined.slice(0, MAX_ATTACHMENTS); + const dropped = combined.length - MAX_ATTACHMENTS; + onAttachmentsChange(kept); + errors.push(`Maximum ${MAX_ATTACHMENTS} files allowed. ${dropped} file(s) not added.`); + } else { + onAttachmentsChange(combined); + } } if (errors.length > 0) { diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2d53211..4fe0604 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -19,6 +19,7 @@ import type { Conversation } from '$lib/types/conversation'; import type { FileAttachment } from '$lib/types/attachment.js'; import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js'; + import { replaceState } from '$app/navigation'; // RAG state let ragEnabled = $state(true); @@ -150,8 +151,8 @@ // Persist user message to IndexedDB with the SAME ID as chatState await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, null, userMessageId); - // Update URL without navigation (keeps ChatWindow mounted) - history.replaceState({}, '', `/chat/${conversationId}`); + // NOTE: URL update moved to onComplete to avoid aborting the stream + // The URL will update after the first response completes // Process attachments if any let contentForOllama = content; @@ -168,13 +169,17 @@ try { // Check if any files need actual LLM analysis - const filesToAnalyze = attachments.filter(a => fileAnalyzer.shouldAnalyze(a)); + // Force analysis when >3 files to prevent context overflow (max 5 files allowed) + const forceAnalysis = attachments.length > 3; + const filesToAnalyze = forceAnalysis + ? attachments.filter(a => a.textContent && a.textContent.length > 2000) + : attachments.filter(a => fileAnalyzer.shouldAnalyze(a)); if (filesToAnalyze.length > 0) { // Update indicator to show analysis chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`); - const analysisResults = await analyzeFilesInBatches(filesToAnalyze, model, 2); + const analysisResults = await analyzeFilesInBatches(filesToAnalyze, model, 3); // Update attachments with results filesToAnalyze.forEach((file) => { @@ -197,7 +202,7 @@ } contentForOllama = formattedParts.join('\n\n'); } else { - // No files need analysis, just format with content + // No files need analysis, format with content const parts: string[] = [content]; for (const a of attachments) { if (a.textContent) { @@ -362,10 +367,16 @@ // Generate a smarter title in the background (don't await) generateSmartTitle(conversationId, content, node.message.content); + + // Update URL now that streaming is complete + replaceState(`/chat/${conversationId}`, {}); } }, onError: (error) => { console.error('Streaming error:', error); + // Show error to user instead of leaving "Processing..." + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); chatState.finishStreaming(); streamingMetricsState.endStream(); } @@ -373,6 +384,9 @@ ); } catch (error) { console.error('Failed to send message:', error); + // Show error to user + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + chatState.setStreamContent(`⚠️ Error: ${errorMsg}`); chatState.finishStreaming(); streamingMetricsState.endStream(); }