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
This commit is contained in:
2026-01-04 01:46:26 +01:00
parent f8fb5ce172
commit 558c035b84
4 changed files with 90 additions and 18 deletions

View File

@@ -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];

View File

@@ -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<OllamaMessage[]> {
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();

View File

@@ -52,6 +52,9 @@
let errorMessage = $state<string | null>(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) {

View File

@@ -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();
}