6 Commits

Author SHA1 Message Date
c048b1343d chore: bump version to 0.4.12
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-04 01:47:58 +01:00
558c035b84 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
2026-01-04 01:46:26 +01:00
f8fb5ce172 fix: keep processing indicator visible until LLM starts streaming
Clear 'Processing...' text only when first token arrives, not before
the LLM request. This keeps the indicator visible during prompt
resolution, RAG retrieval, and LLM initialization.
2026-01-04 00:41:42 +01:00
4084c9a361 feat: add language instruction to always match user's language
LLM will now respond in the same language the user writes in,
defaulting to English if unclear.
2026-01-04 00:37:14 +01:00
26b58fbd50 feat: improve file attachment handling with processing indicator
- Add "Processing X files..." indicator in chat while handling attachments
- Indicator transitions to "Analyzing X files..." for large files needing LLM summarization
- Reuse streaming message for seamless transition to LLM response
- Add FileAnalyzer service for large file summarization with 10s timeout
- Skip analysis for borderline files (within 20% of 8K threshold)
- Read up to 50KB from original file for analysis (not just truncated content)
- Remove base64 blobs from JSON before analysis to reduce prompt size
- Add AttachmentDisplay component for showing file badges on messages
- Persist attachments to IndexedDB with message references
- Add chat state methods: setStreamContent, removeMessage
- Clean up debug logging
2026-01-04 00:35:33 +01:00
3a4aabff1d fix: bundle PDF.js worker locally to fix CDN loading issues
Some checks failed
Create Release / release (push) Has been cancelled
- Add postinstall script to copy worker to static/
- Update Dockerfile to copy worker during build
- Update file-processor to try local worker first, fallback to CDN
- Bump version to 0.4.11
2026-01-03 22:16:19 +01:00
24 changed files with 2165 additions and 207 deletions

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ dev.env
backend/vessel-backend
data/
backend/data-dev/
# Generated files
frontend/static/pdf.worker.min.mjs

View File

@@ -18,7 +18,7 @@ import (
)
// Version is set at build time via -ldflags, or defaults to dev
var Version = "0.4.10"
var Version = "0.4.12"
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {

View File

@@ -12,6 +12,10 @@ RUN npm ci
# Copy source code
COPY . .
# Copy PDF.js worker to static directory for local serving
# This avoids CDN dependency and CORS issues with ESM modules
RUN cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs static/
# Build the application
RUN npm run build

View File

@@ -1,6 +1,6 @@
{
"name": "vessel",
"version": "0.4.10",
"version": "0.4.12",
"private": true,
"type": "module",
"scripts": {
@@ -11,7 +11,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs static/ 2>/dev/null || true"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",

View File

@@ -0,0 +1,270 @@
<script lang="ts">
/**
* AttachmentDisplay - Shows attached files on messages
* Displays compact badges with file info, supports image preview and download
*/
import { getAttachmentMetaByIds, getAttachment, createDownloadUrl } from '$lib/storage';
import type { AttachmentMeta, StoredAttachment } from '$lib/storage';
interface Props {
/** Array of attachment IDs to display */
attachmentIds: string[];
}
const { attachmentIds }: Props = $props();
// Attachment metadata loaded from IndexedDB
let attachments = $state<AttachmentMeta[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Image preview modal state
let previewImage = $state<{ url: string; filename: string } | null>(null);
let downloadingId = $state<string | null>(null);
// Load attachments when IDs change
$effect(() => {
if (attachmentIds.length > 0) {
loadAttachments();
} else {
attachments = [];
loading = false;
}
});
async function loadAttachments(): Promise<void> {
loading = true;
error = null;
const result = await getAttachmentMetaByIds(attachmentIds);
if (result.success) {
attachments = result.data;
} else {
error = result.error;
}
loading = false;
}
/**
* Get icon for attachment type
*/
function getTypeIcon(type: string): string {
switch (type) {
case 'image':
return 'M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z';
case 'pdf':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
case 'text':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
default:
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
}
}
/**
* Get color class for attachment type
*/
function getTypeColor(type: string): string {
switch (type) {
case 'image':
return 'text-violet-400 bg-violet-500/20';
case 'pdf':
return 'text-red-400 bg-red-500/20';
case 'text':
return 'text-emerald-400 bg-emerald-500/20';
default:
return 'text-slate-400 bg-slate-500/20';
}
}
/**
* Format file size for display
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Handle attachment click - preview for images, download for others
*/
async function handleClick(attachment: AttachmentMeta): Promise<void> {
if (attachment.type === 'image') {
await showImagePreview(attachment);
} else {
await downloadAttachment(attachment);
}
}
/**
* Show image preview modal
*/
async function showImagePreview(attachment: AttachmentMeta): Promise<void> {
const result = await getAttachment(attachment.id);
if (result.success && result.data) {
const url = createDownloadUrl(result.data);
previewImage = { url, filename: attachment.filename };
}
}
/**
* Close image preview and revoke URL
*/
function closePreview(): void {
if (previewImage) {
URL.revokeObjectURL(previewImage.url);
previewImage = null;
}
}
/**
* Download attachment
*/
async function downloadAttachment(attachment: AttachmentMeta): Promise<void> {
downloadingId = attachment.id;
try {
const result = await getAttachment(attachment.id);
if (result.success && result.data) {
const url = createDownloadUrl(result.data);
const a = document.createElement('a');
a.href = url;
a.download = attachment.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} finally {
downloadingId = null;
}
}
/**
* Handle keyboard events for attachment buttons
*/
function handleKeydown(event: KeyboardEvent, attachment: AttachmentMeta): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick(attachment);
}
}
</script>
{#if loading}
<div class="flex items-center gap-2 text-sm text-theme-muted">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Loading attachments...</span>
</div>
{:else if error}
<div class="text-sm text-red-400">
Failed to load attachments: {error}
</div>
{:else if attachments.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each attachments as attachment (attachment.id)}
<button
type="button"
onclick={() => handleClick(attachment)}
onkeydown={(e) => handleKeydown(e, attachment)}
class="group flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors hover:brightness-110 {getTypeColor(attachment.type)}"
title={attachment.type === 'image' ? 'Click to preview' : 'Click to download'}
>
<!-- Type icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 flex-shrink-0"
>
<path stroke-linecap="round" stroke-linejoin="round" d={getTypeIcon(attachment.type)} />
</svg>
<!-- Filename (truncated) -->
<span class="max-w-[150px] truncate">
{attachment.filename}
</span>
<!-- Size -->
<span class="text-xs opacity-70">
{formatSize(attachment.size)}
</span>
<!-- Analyzed badge -->
{#if attachment.analyzed}
<span class="rounded bg-amber-500/30 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was summarized by AI">
analyzed
</span>
{/if}
<!-- Download/loading indicator -->
{#if downloadingId === attachment.id}
<svg class="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if attachment.type !== 'image'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Image preview modal -->
{#if previewImage}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Image preview"
>
<button
type="button"
onclick={closePreview}
class="absolute inset-0"
aria-label="Close preview"
></button>
<div class="relative max-h-[90vh] max-w-[90vw]">
<img
src={previewImage.url}
alt={previewImage.filename}
class="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<!-- Close button -->
<button
type="button"
onclick={closePreview}
class="absolute -right-3 -top-3 flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-white shadow-lg hover:bg-slate-700"
aria-label="Close preview"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Filename -->
<div class="mt-2 text-center text-sm text-white/70">
{previewImage.filename}
</div>
</div>
</div>
{/if}

View File

@@ -7,13 +7,14 @@
import { modelsState } from '$lib/stores';
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
import { processFile } from '$lib/utils/file-processor.js';
import { isImageMimeType } from '$lib/types/attachment.js';
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
import FileUpload from './FileUpload.svelte';
interface Props {
onSend?: (content: string, images?: string[]) => void;
/** Callback when message is sent. Includes content, images for vision models, and pending file attachments to persist */
onSend?: (content: string, images?: string[], pendingAttachments?: FileAttachment[]) => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
@@ -47,6 +48,7 @@
// Drag overlay state
let isDragOver = $state(false);
let dragCounter = 0; // Track enter/leave for nested elements
let dragResetTimeout: ReturnType<typeof setTimeout> | null = null;
// Derived state
const hasContent = $derived(
@@ -98,20 +100,14 @@
/**
* Send the message
* Passes raw attachments to ChatWindow which handles analysis
*/
function handleSend(): void {
if (!canSend) return;
let content = inputValue.trim();
const content = inputValue.trim();
const images = pendingImages.length > 0 ? [...pendingImages] : undefined;
// Prepend file attachments content to the message
if (pendingAttachments.length > 0) {
const attachmentContent = formatAttachmentsForMessage(pendingAttachments);
if (attachmentContent) {
content = attachmentContent + (content ? '\n\n' + content : '');
}
}
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
// Clear input, images, and attachments
inputValue = '';
@@ -123,7 +119,8 @@
textareaElement.style.height = 'auto';
}
onSend?.(content, images);
// Pass raw content + attachments to parent (ChatWindow handles analysis)
onSend?.(content, images, attachments);
// Keep focus on input after sending
requestAnimationFrame(() => focusInput());
@@ -172,19 +169,44 @@
$effect(() => {
if (disabled) return;
// Helper to reset drag state with optional delay
function resetDragState(): void {
dragCounter = 0;
isDragOver = false;
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
dragResetTimeout = null;
}
}
// Schedule a fallback reset in case events get lost
function scheduleFallbackReset(): void {
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
}
// Reset after 100ms of no drag activity
dragResetTimeout = setTimeout(() => {
if (isDragOver) {
resetDragState();
}
}, 100);
}
function onDragEnter(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
dragCounter++;
isDragOver = true;
scheduleFallbackReset();
}
function onDragLeave(event: DragEvent): void {
event.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
isDragOver = false;
resetDragState();
} else {
scheduleFallbackReset();
}
}
@@ -194,12 +216,13 @@
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
// Keep resetting the timeout while actively dragging
scheduleFallbackReset();
}
function onDrop(event: DragEvent): void {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
resetDragState();
if (!event.dataTransfer?.files.length) return;
const files = Array.from(event.dataTransfer.files);
@@ -216,6 +239,9 @@
document.removeEventListener('dragleave', onDragLeave);
document.removeEventListener('dragover', onDragOver);
document.removeEventListener('drop', onDrop);
if (dragResetTimeout) {
clearTimeout(dragResetTimeout);
}
};
});
@@ -252,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];
@@ -360,7 +391,7 @@
></textarea>
<!-- Action buttons -->
<div class="flex items-center">
<div class="flex items-center gap-2">
{#if showStopButton}
<!-- Stop button -->
<button

View File

@@ -9,7 +9,10 @@
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
import { ollamaClient } from '$lib/ollama';
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation } from '$lib/storage';
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,
@@ -42,7 +45,7 @@
*/
interface Props {
mode?: 'new' | 'conversation';
onFirstMessage?: (content: string, images?: string[]) => Promise<void>;
onFirstMessage?: (content: string, images?: string[], attachments?: FileAttachment[]) => Promise<void>;
conversation?: Conversation | null;
/** Bindable prop for thinking mode - synced with parent in 'new' mode */
thinkingEnabled?: boolean;
@@ -63,11 +66,15 @@
// Context full modal state
let showContextFullModal = $state(false);
let pendingMessage: { content: string; images?: string[] } | null = $state(null);
let pendingMessage: { content: string; images?: string[]; attachments?: FileAttachment[] } | null = $state(null);
// Tool execution state
let isExecutingTools = $state(false);
// File analysis state
let isAnalyzingFiles = $state(false);
let analyzingFileNames = $state<string[]>([]);
// RAG (Retrieval-Augmented Generation) state
let ragEnabled = $state(true);
let hasKnowledgeBase = $state(false);
@@ -217,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;
}
/**
@@ -328,9 +360,9 @@
// After summarization, try to send the pending message
if (pendingMessage && contextManager.contextUsage.percentage < 100) {
const { content, images } = pendingMessage;
const { content, images, attachments } = pendingMessage;
pendingMessage = null;
await handleSendMessage(content, images);
await handleSendMessage(content, images, attachments);
} else if (pendingMessage) {
// Still full after summarization - show toast
toastState.warning('Context still full after summarization. Try starting a new chat.');
@@ -357,10 +389,10 @@
// Try to send the message anyway (may fail or get truncated)
if (pendingMessage) {
const { content, images } = pendingMessage;
const { content, images, attachments } = pendingMessage;
pendingMessage = null;
// Bypass the context check by calling the inner logic directly
await sendMessageInternal(content, images);
await sendMessageInternal(content, images, attachments);
}
}
@@ -372,7 +404,7 @@
/**
* Send a message - checks context and may show modal
*/
async function handleSendMessage(content: string, images?: string[]): Promise<void> {
async function handleSendMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) {
@@ -383,24 +415,24 @@
// Check if context is full (100%+)
if (contextManager.contextUsage.percentage >= 100) {
// Store pending message and show modal
pendingMessage = { content, images };
pendingMessage = { content, images, attachments };
showContextFullModal = true;
return;
}
await sendMessageInternal(content, images);
await sendMessageInternal(content, images, attachments);
}
/**
* Internal: Send message and stream response (bypasses context check)
*/
async function sendMessageInternal(content: string, images?: string[]): Promise<void> {
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const selectedModel = modelsState.selectedId;
if (!selectedModel) return;
// In 'new' mode with no messages yet, create conversation first
if (mode === 'new' && !hasMessages && onFirstMessage) {
await onFirstMessage(content, images);
await onFirstMessage(content, images, attachments);
return;
}
@@ -423,36 +455,161 @@
}
}
// Add user message to tree
// Collect attachment IDs if we have attachments to save
let attachmentIds: string[] | undefined;
if (attachments && attachments.length > 0) {
attachmentIds = attachments.map(a => a.id);
}
// Add user message to tree (including attachmentIds for display)
const userMessageId = chatState.addMessage({
role: 'user',
content,
images
images,
attachmentIds
});
// Persist user message to IndexedDB with the SAME ID as chatState
// Persist user message and attachments to IndexedDB
if (conversationId) {
const parentId = chatState.activePath.length >= 2
? chatState.activePath[chatState.activePath.length - 2]
: null;
await addStoredMessage(conversationId, { role: 'user', content, images }, parentId, userMessageId);
// Save attachments first (they need the messageId)
if (attachments && attachments.length > 0) {
// Use original File objects for storage (preserves binary data)
const files = attachments.map((a) => {
if (a.originalFile) {
return a.originalFile;
}
// Fallback: reconstruct from processed data (shouldn't be needed normally)
if (a.base64Data) {
const binary = atob(a.base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], a.filename, { type: a.mimeType });
}
// For text/PDF without original, create placeholder (download won't work)
console.warn(`No original file for attachment ${a.filename}, download may not work`);
return new File([a.textContent || ''], a.filename, { type: a.mimeType });
});
const saveResult = await saveAttachments(userMessageId, files, attachments);
if (!saveResult.success) {
console.error('Failed to save attachments:', saveResult.error);
}
}
// Save message with attachmentIds
await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, parentId, userMessageId);
}
// Stream assistant message with optional tool support
await streamAssistantResponse(selectedModel, userMessageId, conversationId);
// Process attachments if any
let contentForOllama = content;
let processingMessageId: string | undefined;
if (attachments && attachments.length > 0) {
// Show processing indicator - this message will become the assistant response
isAnalyzingFiles = true;
analyzingFileNames = attachments.map(a => a.filename);
processingMessageId = chatState.startStreaming();
const fileCount = attachments.length;
const fileLabel = fileCount === 1 ? 'file' : 'files';
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
try {
// Check if any files need actual LLM analysis
// 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, 3);
// Update attachments with results
filesToAnalyze.forEach((file) => {
const result = analysisResults.get(file.id);
if (result) {
file.analyzed = result.analyzed;
file.summary = result.summary;
}
});
// Build formatted content with file summaries
const formattedParts: string[] = [content];
for (const attachment of attachments) {
const result = analysisResults.get(attachment.id);
if (result) {
formattedParts.push(formatAnalyzedAttachment(attachment, result));
} else if (attachment.textContent) {
// Non-analyzed text attachment
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
}
}
contentForOllama = formattedParts.join('\n\n');
} else {
// No files need analysis, format with content
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
}
// Keep "Processing..." visible - LLM streaming will replace it
} catch (error) {
console.error('[ChatWindow] File processing failed:', error);
chatState.setStreamContent('Processing failed, proceeding with original content...');
await new Promise(r => setTimeout(r, 1000));
// Fallback: use original content with raw file text
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
} finally {
isAnalyzingFiles = false;
analyzingFileNames = [];
}
}
// Stream assistant message (reuse processing message if it exists)
await streamAssistantResponse(selectedModel, userMessageId, conversationId, contentForOllama, processingMessageId);
}
/**
* Stream assistant response with tool call handling and RAG context
* @param contentOverride Optional content to use instead of the last user message content (for formatted attachments)
*/
async function streamAssistantResponse(
model: string,
parentMessageId: string,
conversationId: string | null
conversationId: string | null,
contentOverride?: string,
existingMessageId?: string
): Promise<void> {
const assistantMessageId = chatState.startStreaming();
// Reuse existing message (e.g., from "Processing..." indicator) or create new one
const assistantMessageId = existingMessageId || chatState.startStreaming();
abortController = new AbortController();
// Track if we need to clear the "Processing..." text on first token
let needsClearOnFirstToken = !!existingMessageId;
// Start streaming metrics tracking
streamingMetricsState.startStream();
@@ -460,9 +617,21 @@
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
if (contentOverride && messages.length > 0) {
const lastUserIndex = messages.findLastIndex(m => m.role === 'user');
if (lastUserIndex !== -1) {
messages = [
...messages.slice(0, lastUserIndex),
{ ...messages[lastUserIndex], content: contentOverride },
...messages.slice(lastUserIndex + 1)
];
}
}
// Build system prompt from resolution service + RAG context
const systemParts: string[] = [];
@@ -494,6 +663,9 @@
}
}
// Always add language instruction
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
// Inject combined system message
if (systemParts.length > 0) {
const systemMessage: OllamaMessage = {
@@ -525,6 +697,11 @@
},
{
onThinkingToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Accumulate thinking and update the message
if (!streamingThinking) {
// Start the thinking block
@@ -536,6 +713,11 @@
streamingMetricsState.incrementTokens();
},
onToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
@@ -591,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;
@@ -599,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();
@@ -747,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
@@ -804,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;
@@ -812,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

@@ -1,11 +1,10 @@
<script lang="ts">
/**
* FilePreview.svelte - Preview for attached text/PDF files
* Shows filename, size, and expandable content preview
* Includes remove button on hover
* FilePreview.svelte - Compact preview badge for attached files
* Shows filename, size, and type - no raw content dump
*/
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatFileSize, getFileIcon } from '$lib/utils/file-processor.js';
import { formatFileSize } from '$lib/utils/file-processor.js';
interface Props {
attachment: FileAttachment;
@@ -13,99 +12,124 @@
readonly?: boolean;
}
const { attachment, onRemove, readonly = false }: Props = $props();
const props: Props = $props();
// Expansion state for content preview
let isExpanded = $state(false);
// Truncate preview to first N characters
const PREVIEW_LENGTH = 200;
const hasContent = attachment.textContent && attachment.textContent.length > 0;
const previewText = $derived(
attachment.textContent
? attachment.textContent.slice(0, PREVIEW_LENGTH) +
(attachment.textContent.length > PREVIEW_LENGTH ? '...' : '')
: ''
);
// Derived values to ensure reactivity
const attachment = $derived(props.attachment);
const onRemove = $derived(props.onRemove);
const readonly = $derived(props.readonly ?? false);
function handleRemove() {
onRemove?.(attachment.id);
}
function toggleExpand() {
if (hasContent) {
isExpanded = !isExpanded;
/**
* Get icon path for attachment type
*/
function getIconPath(type: string): string {
switch (type) {
case 'pdf':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
case 'text':
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
default:
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
}
}
/**
* Get color classes for attachment type
*/
function getTypeStyle(type: string): { icon: string; badge: string; badgeText: string } {
switch (type) {
case 'pdf':
return {
icon: 'text-red-400',
badge: 'bg-red-500/20',
badgeText: 'text-red-300'
};
case 'text':
return {
icon: 'text-emerald-400',
badge: 'bg-emerald-500/20',
badgeText: 'text-emerald-300'
};
default:
return {
icon: 'text-slate-400',
badge: 'bg-slate-500/20',
badgeText: 'text-slate-300'
};
}
}
/**
* Get file extension for display
*/
function getExtension(filename: string): string {
const ext = filename.split('.').pop()?.toUpperCase();
return ext || 'FILE';
}
const style = $derived(getTypeStyle(attachment.type));
const extension = $derived(getExtension(attachment.filename));
</script>
<div
class="group relative flex items-start gap-3 rounded-lg border border-theme/50 bg-theme-secondary/50 p-3 transition-colors hover:bg-theme-secondary"
class="group relative inline-flex items-center gap-2.5 rounded-xl border border-theme/30 bg-theme-secondary/60 px-3 py-2 transition-all hover:border-theme/50 hover:bg-theme-secondary"
>
<!-- File icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-theme-tertiary/50 text-lg">
{getFileIcon(attachment.type)}
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {style.badge}">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 {style.icon}"
>
<path stroke-linecap="round" stroke-linejoin="round" d={getIconPath(attachment.type)} />
</svg>
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-theme-secondary" title={attachment.filename}>
{attachment.filename}
</p>
<p class="text-xs text-theme-muted">
{formatFileSize(attachment.size)}
{#if attachment.type === 'pdf'}
<span class="text-theme-muted">·</span>
<span class="text-violet-400">PDF</span>
{/if}
</p>
</div>
<!-- Remove button (only when not readonly) -->
{#if !readonly && onRemove}
<button
type="button"
onclick={handleRemove}
class="shrink-0 rounded p-1 text-theme-muted opacity-0 transition-all hover:bg-red-900/30 hover:text-red-400 group-hover:opacity-100"
aria-label="Remove file"
title="Remove"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
<p class="max-w-[180px] truncate text-sm font-medium text-theme-primary" title={attachment.filename}>
{attachment.filename}
</p>
<div class="flex items-center gap-1.5 text-xs text-theme-muted">
<span>{formatFileSize(attachment.size)}</span>
<span class="opacity-50">·</span>
<span class="rounded px-1 py-0.5 text-[10px] font-medium {style.badge} {style.badgeText}">
{extension}
</span>
{#if attachment.truncated}
<span class="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was truncated due to size">
truncated
</span>
{/if}
</div>
<!-- Content preview (expandable) -->
{#if hasContent}
<button
type="button"
onclick={toggleExpand}
class="mt-2 w-full text-left"
>
<div
class="rounded border border-theme/50 bg-theme-primary/50 p-2 text-xs text-theme-muted transition-colors hover:border-theme-subtle"
>
{#if isExpanded}
<pre class="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono">{attachment.textContent}</pre>
{:else}
<p class="truncate font-mono">{previewText}</p>
{/if}
<p class="mt-1 text-[10px] text-theme-muted">
{isExpanded ? 'Click to collapse' : 'Click to expand'}
</p>
</div>
</button>
{/if}
</div>
<!-- Remove button -->
{#if !readonly && onRemove}
<button
type="button"
onclick={handleRemove}
class="ml-1 shrink-0 rounded-lg p-1.5 text-theme-muted opacity-0 transition-all hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
aria-label="Remove file"
title="Remove"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
{/if}
</div>

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);
@@ -73,17 +76,21 @@
/**
* Process multiple files
* @param files - Files to process
* @param fromPaste - Whether files came from a paste event (affects image handling)
*/
async function processFiles(files: File[]) {
async function processFiles(files: File[], fromPaste = false) {
isProcessing = true;
errorMessage = null;
const newAttachments: FileAttachment[] = [];
const errors: string[] = [];
const imageFiles: File[] = [];
for (const file of files) {
// Skip images - they're handled by ImageUpload
// Collect images separately
if (isImageMimeType(file.type)) {
imageFiles.push(file);
continue;
}
@@ -95,8 +102,27 @@
}
}
// Handle collected image files
if (imageFiles.length > 0) {
if (supportsVision) {
// Forward to image processing
await processImageFiles(imageFiles);
} else if (!fromPaste) {
// Only show error if user explicitly selected images (not paste)
errors.push(`Images require a vision-capable model (e.g., llava, bakllava)`);
}
}
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) {
@@ -152,7 +178,8 @@
// Handle non-image files
if (files.length > 0) {
processFiles(files);
event.preventDefault(); // Prevent browser from pasting as text
processFiles(files, true);
}
// Handle image files

View File

@@ -48,7 +48,12 @@
* Process and add files to the images array
*/
async function handleFiles(files: FileList | File[]): Promise<void> {
if (!canAddMore) return;
if (!canAddMore) {
// Show error when max reached
errorMessage = `Maximum ${maxImages} images allowed`;
setTimeout(() => { errorMessage = null; }, 3000);
return;
}
const fileArray = Array.from(files);
const validFiles = fileArray.filter(isValidImageType);
@@ -63,13 +68,15 @@
const remainingSlots = maxImages - images.length;
const filesToProcess = validFiles.slice(0, remainingSlots);
// Clear previous error when starting new upload
errorMessage = null;
if (filesToProcess.length < validFiles.length) {
errorMessage = `Only ${remainingSlots} image${remainingSlots === 1 ? '' : 's'} can be added. Maximum: ${maxImages}`;
setTimeout(() => { errorMessage = null; }, 3000);
}
isProcessing = true;
errorMessage = null;
try {
const newImages: string[] = [];

View File

@@ -29,6 +29,9 @@
// Supports both <thinking>...</thinking> and <think>...</think> (qwen3 format)
const THINKING_PATTERN = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/g;
// Pattern to find file attachment blocks (content shown via AttachmentDisplay badges instead)
const FILE_BLOCK_PATTERN = /<file\s+[^>]*>[\s\S]*?<\/file>/g;
// Pattern to detect JSON tool call objects (for models that output them as text)
// Matches: {"name": "...", "arguments": {...}}
const JSON_TOOL_CALL_PATTERN = /^(\s*\{[\s\S]*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[\s\S]*\}\s*\}\s*)$/;
@@ -72,6 +75,13 @@
.trim();
}
/**
* Strip file attachment blocks (content shown via AttachmentDisplay)
*/
function stripFileBlocks(text: string): string {
return text.replace(FILE_BLOCK_PATTERN, '').trim();
}
/**
* Check if text contains tool execution results
*/
@@ -319,7 +329,8 @@
}
// Clean and parse content into parts
const cleanedContent = $derived(cleanToolText(content));
// Strip file blocks (shown via AttachmentDisplay) and tool text
const cleanedContent = $derived(stripFileBlocks(cleanToolText(content)));
const parsedContent = $derived.by(() => {
const result = parseContent(cleanedContent);
// Debug: Log if thinking blocks were found

View File

@@ -10,6 +10,7 @@
import BranchNavigator from './BranchNavigator.svelte';
import StreamingIndicator from './StreamingIndicator.svelte';
import ToolCallDisplay from './ToolCallDisplay.svelte';
import AttachmentDisplay from './AttachmentDisplay.svelte';
interface Props {
node: MessageNode;
@@ -43,6 +44,7 @@
const isSystem = $derived(node.message.role === 'system');
const hasContent = $derived(node.message.content.length > 0);
const hasToolCalls = $derived(node.message.toolCalls && node.message.toolCalls.length > 0);
const hasAttachments = $derived(node.message.attachmentIds && node.message.attachmentIds.length > 0);
// Detect summary messages (compressed conversation history)
const isSummaryMessage = $derived(node.message.isSummary === true);
@@ -228,6 +230,10 @@
/>
{/if}
{#if hasAttachments && node.message.attachmentIds}
<AttachmentDisplay attachmentIds={node.message.attachmentIds} />
{/if}
{#if hasToolCalls && node.message.toolCalls}
<ToolCallDisplay toolCalls={node.message.toolCalls} />
{/if}

View File

@@ -24,6 +24,9 @@ export { default as ChatInput } from './ChatInput.svelte';
export { default as ImageUpload } from './ImageUpload.svelte';
export { default as ImagePreview } from './ImagePreview.svelte';
// Attachment display
export { default as AttachmentDisplay } from './AttachmentDisplay.svelte';
// Code display
export { default as CodeBlock } from './CodeBlock.svelte';

View File

@@ -0,0 +1,372 @@
/**
* Attachment Service
* Coordinates file processing, analysis, and storage for message attachments.
* Acts as the main API for attachment operations in the chat flow.
*/
import { processFile } from '$lib/utils/file-processor.js';
import { fileAnalyzer, type AnalysisResult } from './fileAnalyzer.js';
import {
saveAttachment,
saveAttachments as saveAttachmentsToDb,
getAttachment,
getAttachmentsByIds,
getAttachmentMetaForMessage,
getAttachmentMetaByIds,
getAttachmentBase64,
getAttachmentTextContent,
createDownloadUrl,
deleteAttachment,
deleteAttachmentsByIds,
updateAttachmentAnalysis
} from '$lib/storage/index.js';
import type { StoredAttachment, AttachmentMeta, StorageResult } from '$lib/storage/index.js';
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
/**
* Pending attachment before it's saved to IndexedDB.
* Contains the processed file data and original File object.
*/
export interface PendingAttachment {
file: File;
attachment: FileAttachment;
analysisResult?: AnalysisResult;
}
/**
* Success result of preparing an attachment.
*/
export interface PrepareSuccess {
success: true;
pending: PendingAttachment;
}
/**
* Error result of preparing an attachment.
*/
export interface PrepareError {
success: false;
error: string;
}
/**
* Result of preparing an attachment for sending.
*/
export type PrepareResult = PrepareSuccess | PrepareError;
/**
* Content formatted for inclusion in a message.
*/
export interface FormattedContent {
/** The formatted text content (XML-style tags) */
text: string;
/** Whether any content was analyzed by the sub-agent */
hasAnalyzed: boolean;
/** Total original size of all attachments */
totalSize: number;
}
class AttachmentService {
/**
* Prepare a single file as a pending attachment.
* Processes the file but does not persist to storage.
*/
async prepareAttachment(file: File): Promise<PrepareResult> {
const result = await processFile(file);
if (!result.success) {
return { success: false, error: result.error };
}
return {
success: true,
pending: { file, attachment: result.attachment }
};
}
/**
* Prepare multiple files as pending attachments.
* Returns both successful preparations and any errors.
*/
async prepareAttachments(files: File[]): Promise<{
pending: PendingAttachment[];
errors: string[];
}> {
const pending: PendingAttachment[] = [];
const errors: string[] = [];
for (const file of files) {
const result = await this.prepareAttachment(file);
if (result.success) {
pending.push(result.pending);
} else {
errors.push(`${file.name}: ${result.error}`);
}
}
return { pending, errors };
}
/**
* Analyze pending attachments that exceed size thresholds.
* Spawns sub-agent for large files to summarize content.
*/
async analyzeIfNeeded(
pending: PendingAttachment[],
model: string
): Promise<PendingAttachment[]> {
const analyzed: PendingAttachment[] = [];
for (const item of pending) {
const result = await fileAnalyzer.analyzeIfNeeded(item.attachment, model);
analyzed.push({
...item,
analysisResult: result
});
}
return analyzed;
}
/**
* Save pending attachments to IndexedDB, linking them to a message.
* Returns the attachment IDs for storing in the message.
*/
async savePendingAttachments(
messageId: string,
pending: PendingAttachment[]
): Promise<StorageResult<string[]>> {
const files = pending.map(p => p.file);
const attachments = pending.map(p => p.attachment);
const result = await saveAttachmentsToDb(messageId, files, attachments);
if (result.success) {
// Update analysis status if any were analyzed
for (let i = 0; i < pending.length; i++) {
const item = pending[i];
if (item.analysisResult?.analyzed) {
await updateAttachmentAnalysis(
attachments[i].id,
true,
item.analysisResult.summary
);
}
}
}
return result;
}
/**
* Format pending attachments for inclusion in message content.
* Uses analysis summaries for large files, raw content for small ones.
*/
formatForMessage(pending: PendingAttachment[]): FormattedContent {
let hasAnalyzed = false;
let totalSize = 0;
const parts: string[] = [];
for (const item of pending) {
const { attachment, analysisResult } = item;
totalSize += attachment.size;
// Skip images - they go in the images array, not text content
if (attachment.type === 'image') {
continue;
}
// Skip if no text content to include
if (!attachment.textContent && !analysisResult?.summary) {
continue;
}
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
const typeAttr = ` type="${attachment.type}"`;
if (analysisResult && !analysisResult.useOriginal && analysisResult.summary) {
// Use analyzed summary for large files
hasAnalyzed = true;
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr} analyzed="true">\n` +
`${analysisResult.summary}\n` +
`[Full content (${formatFileSize(analysisResult.originalLength)}) stored locally]\n` +
`</file>`
);
} else {
// Use raw content for small files
const content = analysisResult?.content || attachment.textContent || '';
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
`${content}\n` +
`</file>`
);
}
}
return {
text: parts.join('\n\n'),
hasAnalyzed,
totalSize
};
}
/**
* Get image base64 data for Ollama from pending attachments.
* Returns array of base64 strings (without data: prefix).
*/
getImagesFromPending(pending: PendingAttachment[]): string[] {
return pending
.filter(p => p.attachment.type === 'image' && p.attachment.base64Data)
.map(p => p.attachment.base64Data!);
}
/**
* Load attachment metadata for display (without binary data).
*/
async getMetaForMessage(messageId: string): Promise<StorageResult<AttachmentMeta[]>> {
return getAttachmentMetaForMessage(messageId);
}
/**
* Load attachment metadata by IDs.
*/
async getMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
return getAttachmentMetaByIds(ids);
}
/**
* Load full attachment data by ID.
*/
async getFullAttachment(id: string): Promise<StorageResult<StoredAttachment | null>> {
return getAttachment(id);
}
/**
* Load multiple full attachments by IDs.
*/
async getFullAttachments(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
return getAttachmentsByIds(ids);
}
/**
* Get base64 data for an image attachment (for Ollama).
*/
async getImageBase64(id: string): Promise<StorageResult<string | null>> {
return getAttachmentBase64(id);
}
/**
* Get text content from an attachment.
*/
async getTextContent(id: string): Promise<StorageResult<string | null>> {
return getAttachmentTextContent(id);
}
/**
* Create a download URL for an attachment.
* Remember to call URL.revokeObjectURL() when done.
*/
async createDownloadUrl(id: string): Promise<string | null> {
const result = await getAttachment(id);
if (!result.success || !result.data) {
return null;
}
return createDownloadUrl(result.data);
}
/**
* Delete a single attachment.
*/
async deleteAttachment(id: string): Promise<StorageResult<void>> {
return deleteAttachment(id);
}
/**
* Delete multiple attachments.
*/
async deleteAttachments(ids: string[]): Promise<StorageResult<void>> {
return deleteAttachmentsByIds(ids);
}
/**
* Build images array for Ollama from stored attachment IDs.
* Loads image base64 data from IndexedDB.
*/
async buildOllamaImages(attachmentIds: string[]): Promise<string[]> {
const images: string[] = [];
for (const id of attachmentIds) {
const result = await getAttachmentBase64(id);
if (result.success && result.data) {
images.push(result.data);
}
}
return images;
}
/**
* Build text content for Ollama from stored attachment IDs.
* Returns formatted XML-style content for non-image attachments.
*/
async buildOllamaContent(attachmentIds: string[]): Promise<string> {
const attachments = await getAttachmentsByIds(attachmentIds);
if (!attachments.success) {
return '';
}
const parts: string[] = [];
for (const attachment of attachments.data) {
// Skip images - they go in images array
if (attachment.type === 'image') {
continue;
}
const content = attachment.textContent || await attachment.data.text().catch(() => null);
if (!content) {
continue;
}
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
const typeAttr = ` type="${attachment.type}"`;
const analyzedAttr = attachment.analyzed ? ' analyzed="true"' : '';
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
if (attachment.analyzed && attachment.summary) {
// Use stored summary
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${analyzedAttr}>\n` +
`${attachment.summary}\n` +
`[Full content stored locally]\n` +
`</file>`
);
} else {
// Use raw content
parts.push(
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
`${content}\n` +
`</file>`
);
}
}
return parts.join('\n\n');
}
}
// Helpers
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function escapeXmlAttr(str: string): string {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Singleton export
export const attachmentService = new AttachmentService();

View File

@@ -0,0 +1,407 @@
/**
* File Analyzer Service
*
* Spawns a separate Ollama request to analyze/summarize large files
* before adding them to the main conversation context.
* This keeps the main context clean for conversation while still
* allowing the model to understand file contents.
*/
import { ollamaClient } from '$lib/ollama';
import type { FileAttachment } from '$lib/types/attachment.js';
import { ANALYSIS_THRESHOLD, MAX_EXTRACTED_CONTENT } from '$lib/types/attachment.js';
import { formatFileSize } from '$lib/utils/file-processor.js';
// ============================================================================
// Types
// ============================================================================
export interface AnalysisResult {
/** Whether to use the original content (file was small enough) */
useOriginal: boolean;
/** The content to use in the message (original or summary) */
content: string;
/** Summary generated by the analysis agent (if analyzed) */
summary?: string;
/** Original content size in characters */
originalLength: number;
/** Whether the file was analyzed by the sub-agent */
analyzed: boolean;
/** Error message if analysis failed */
error?: string;
}
export interface FileAnalyzerConfig {
/** Size thresholds for different file types (in bytes) */
thresholds: {
text: number;
pdf: number;
json: number;
};
/** Timeout for analysis request (ms) */
timeout: number;
/** Maximum tokens for analysis response */
maxResponseTokens: number;
}
// ============================================================================
// Default Configuration
// ============================================================================
const DEFAULT_CONFIG: FileAnalyzerConfig = {
thresholds: {
text: 500 * 1024, // 500KB for general text
pdf: 1024 * 1024, // 1MB for PDFs
json: 300 * 1024, // 300KB for JSON (dense data)
},
timeout: 10000, // 10 seconds - fail fast, fall back to truncated
maxResponseTokens: 256, // Keep summaries very concise for speed
};
/** Maximum content to read from file for analysis (50KB) */
const MAX_ANALYSIS_CONTENT = 50 * 1024;
/** If content is within this % of threshold, skip analysis */
const BORDERLINE_THRESHOLD_PERCENT = 0.2; // 20%
// ============================================================================
// Content Reading
// ============================================================================
/**
* Read full content from original file for analysis
* Returns up to MAX_ANALYSIS_CONTENT chars
*/
async function readFullContentForAnalysis(attachment: FileAttachment): Promise<string> {
// If we have the original file, read from it
if (attachment.originalFile) {
try {
const text = await attachment.originalFile.text();
// Limit to max analysis content
if (text.length > MAX_ANALYSIS_CONTENT) {
return text.slice(0, MAX_ANALYSIS_CONTENT);
}
return text;
} catch (err) {
console.warn('[FileAnalyzer] Failed to read original file:', err);
}
}
// Fall back to stored textContent
return attachment.textContent || '';
}
// ============================================================================
// Content Cleaning
// ============================================================================
/**
* Remove base64 blobs and large binary data from JSON content
* Replaces them with descriptive placeholders
*/
function cleanJsonForAnalysis(content: string): { cleaned: string; blobsRemoved: number } {
let blobsRemoved = 0;
// Pattern to match base64 data (common patterns in JSON)
// Matches: "data:image/...;base64,..." or long base64 strings (>100 chars of base64 alphabet)
const base64DataUrlPattern = /"data:[^"]*;base64,[A-Za-z0-9+/=]+"/g;
const longBase64Pattern = /"[A-Za-z0-9+/=]{100,}"/g;
let cleaned = content;
// Replace data URLs
cleaned = cleaned.replace(base64DataUrlPattern, (match) => {
blobsRemoved++;
// Extract mime type if possible
const mimeMatch = match.match(/data:([^;]+);/);
const mime = mimeMatch ? mimeMatch[1] : 'binary';
return `"[BLOB: ${mime} data removed]"`;
});
// Replace remaining long base64 strings
cleaned = cleaned.replace(longBase64Pattern, () => {
blobsRemoved++;
return '"[BLOB: large binary data removed]"';
});
return { cleaned, blobsRemoved };
}
// ============================================================================
// Analysis Prompts
// ============================================================================
/**
* Build an analysis prompt based on file type
* @param attachment The file attachment metadata
* @param rawContent The full content to analyze (from original file)
*/
function buildAnalysisPrompt(attachment: FileAttachment, rawContent: string): string {
let content = rawContent;
const fileTypeHint = getFileTypeHint(attachment);
let blobNote = '';
// For JSON files, remove blobs to reduce size
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
if (mime === 'application/json' || ext === 'json') {
const { cleaned, blobsRemoved } = cleanJsonForAnalysis(content);
content = cleaned;
if (blobsRemoved > 0) {
blobNote = `\n(Note: ${blobsRemoved} binary blob(s) were removed from the JSON for analysis)`;
}
}
return `Summarize this ${fileTypeHint} in 2-3 sentences. Focus on: what it is, key data/content, and structure.${blobNote}
<file name="${attachment.filename}">
${content}
</file>
Summary:`;
}
/**
* Get a human-readable hint about the file type
*/
function getFileTypeHint(attachment: FileAttachment): string {
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
if (mime === 'application/json' || ext === 'json') {
return 'JSON data file';
}
if (mime === 'application/pdf' || ext === 'pdf') {
return 'PDF document';
}
if (ext === 'md' || ext === 'markdown') {
return 'Markdown document';
}
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp'].includes(ext || '')) {
return `${ext?.toUpperCase()} source code file`;
}
if (['yaml', 'yml', 'toml', 'ini', 'cfg', 'conf'].includes(ext || '')) {
return 'configuration file';
}
if (ext === 'csv') {
return 'CSV data file';
}
if (ext === 'xml') {
return 'XML document';
}
if (ext === 'html' || ext === 'htm') {
return 'HTML document';
}
return 'text file';
}
// ============================================================================
// File Analyzer Class
// ============================================================================
export class FileAnalyzer {
private config: FileAnalyzerConfig;
constructor(config: Partial<FileAnalyzerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get the size threshold for a given attachment type
*/
private getThreshold(attachment: FileAttachment): number {
const ext = attachment.filename.split('.').pop()?.toLowerCase();
const mime = attachment.mimeType.toLowerCase();
// JSON files are dense, use lower threshold
if (mime === 'application/json' || ext === 'json') {
return this.config.thresholds.json;
}
// PDFs can be larger
if (mime === 'application/pdf' || ext === 'pdf') {
return this.config.thresholds.pdf;
}
// Default text threshold
return this.config.thresholds.text;
}
/**
* Check if a file should be analyzed (based on content size)
* Skips analysis for borderline files (within 20% of threshold)
*/
shouldAnalyze(attachment: FileAttachment): boolean {
const contentLength = attachment.textContent?.length || 0;
// Below threshold - no analysis needed
if (contentLength <= ANALYSIS_THRESHOLD) {
return false;
}
// Check if borderline (within 20% of threshold)
// These files are small enough to just use directly
const borderlineLimit = ANALYSIS_THRESHOLD * (1 + BORDERLINE_THRESHOLD_PERCENT);
if (contentLength <= borderlineLimit) {
return false;
}
return true;
}
/**
* Analyze a file attachment if needed
* Returns either the original content (for small files) or a summary (for large files)
*/
async analyzeIfNeeded(
attachment: FileAttachment,
model: string
): Promise<AnalysisResult> {
const contentLength = attachment.textContent?.length || 0;
// Small files or borderline: use original content
if (!this.shouldAnalyze(attachment)) {
return {
useOriginal: true,
content: attachment.textContent || '',
originalLength: contentLength,
analyzed: false,
};
}
// Large files: spawn analysis agent with timeout
const startTime = Date.now();
try {
// Race between analysis and timeout
const summary = await Promise.race([
this.spawnAnalysisAgent(attachment, model),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Analysis timeout')), this.config.timeout)
)
]);
return {
useOriginal: false,
content: summary,
summary,
originalLength: contentLength,
analyzed: true,
};
} catch (error) {
const elapsed = Date.now() - startTime;
const isTimeout = error instanceof Error && error.message === 'Analysis timeout';
console.warn(`[FileAnalyzer] Analysis ${isTimeout ? 'TIMEOUT' : 'FAILED'} for ${attachment.filename} after ${elapsed}ms`);
// Fallback: use truncated content (faster than waiting for slow analysis)
const truncated = attachment.textContent?.slice(0, ANALYSIS_THRESHOLD) || '';
const reason = isTimeout ? 'timed out' : 'failed';
return {
useOriginal: false,
content: truncated + `\n\n[Analysis ${reason} - showing first ${formatFileSize(ANALYSIS_THRESHOLD)} of ${formatFileSize(contentLength)}]`,
originalLength: contentLength,
analyzed: false,
error: error instanceof Error ? error.message : 'Analysis failed',
};
}
}
/**
* Spawn a separate Ollama request to analyze the file
*/
private async spawnAnalysisAgent(
attachment: FileAttachment,
model: string
): Promise<string> {
// Read full content from original file if available
const fullContent = await readFullContentForAnalysis(attachment);
const prompt = buildAnalysisPrompt(attachment, fullContent);
// Use generate for a simple completion
const response = await ollamaClient.generate({
model,
prompt,
options: {
temperature: 0.3, // Lower temperature for consistent summaries
num_predict: this.config.maxResponseTokens,
}
});
return response.response.trim();
}
}
// ============================================================================
// Singleton Instance
// ============================================================================
export const fileAnalyzer = new FileAnalyzer();
// ============================================================================
// Batch Analysis Helper
// ============================================================================
/**
* Analyze multiple files with concurrency limit
* @param files Files to analyze
* @param model Model to use
* @param maxConcurrent Maximum parallel analyses (default 2)
*/
export async function analyzeFilesInBatches(
files: FileAttachment[],
model: string,
maxConcurrent: number = 2
): Promise<Map<string, AnalysisResult>> {
const results = new Map<string, AnalysisResult>();
// Process in batches of maxConcurrent
for (let i = 0; i < files.length; i += maxConcurrent) {
const batch = files.slice(i, i + maxConcurrent);
const batchResults = await Promise.all(
batch.map(file => fileAnalyzer.analyzeIfNeeded(file, model))
);
batch.forEach((file, idx) => {
results.set(file.id, batchResults[idx]);
});
}
return results;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Format an analyzed attachment for inclusion in a message
*/
export function formatAnalyzedAttachment(
attachment: FileAttachment,
result: AnalysisResult
): string {
if (result.analyzed && result.summary) {
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}" analyzed="true">
## Summary (original: ${formatFileSize(result.originalLength)} chars)
${result.summary}
</file>`;
}
// Not analyzed, use content directly
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}">
${result.content}
</file>`;
}
/**
* Escape special characters for XML attribute values
*/
function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -4,18 +4,15 @@
*/
import { db, withErrorHandling, generateId } from './db.js';
import type { StoredAttachment, StorageResult } from './db.js';
import type { StoredAttachment, AttachmentMeta, StorageResult } from './db.js';
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
/**
* Attachment metadata without the binary data
*/
export interface AttachmentMeta {
id: string;
messageId: string;
mimeType: string;
filename: string;
size: number;
}
// Re-export AttachmentMeta for convenience
export type { AttachmentMeta };
// ============================================================================
// Query Operations
// ============================================================================
/**
* Get all attachments for a message
@@ -29,20 +26,14 @@ export async function getAttachmentsForMessage(
}
/**
* Get attachment metadata (without data) for a message
* Get attachment metadata (without binary data) for a message
*/
export async function getAttachmentMetaForMessage(
messageId: string
): Promise<StorageResult<AttachmentMeta[]>> {
return withErrorHandling(async () => {
const attachments = await db.attachments.where('messageId').equals(messageId).toArray();
return attachments.map((a) => ({
id: a.id,
messageId: a.messageId,
mimeType: a.mimeType,
filename: a.filename,
size: a.data.size
}));
return attachments.map(toAttachmentMeta);
});
}
@@ -56,25 +47,92 @@ export async function getAttachment(id: string): Promise<StorageResult<StoredAtt
}
/**
* Add an attachment to a message
* Get multiple attachments by IDs
*/
export async function addAttachment(
messageId: string,
file: File
): Promise<StorageResult<StoredAttachment>> {
export async function getAttachmentsByIds(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
return withErrorHandling(async () => {
const id = generateId();
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
// Maintain order of input IDs
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
return ids.map(id => attachmentMap.get(id)).filter((a): a is StoredAttachment => a !== undefined);
});
}
const attachment: StoredAttachment = {
id,
/**
* Get metadata for multiple attachments by IDs
*/
export async function getAttachmentMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
return withErrorHandling(async () => {
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
return ids
.map(id => attachmentMap.get(id))
.filter((a): a is StoredAttachment => a !== undefined)
.map(toAttachmentMeta);
});
}
// ============================================================================
// Create Operations
// ============================================================================
/**
* Save a FileAttachment to IndexedDB with the original file data
* Returns the attachment ID for linking to the message
*/
export async function saveAttachment(
messageId: string,
file: File,
attachment: FileAttachment
): Promise<StorageResult<string>> {
return withErrorHandling(async () => {
const stored: StoredAttachment = {
id: attachment.id,
messageId,
mimeType: file.type || 'application/octet-stream',
mimeType: attachment.mimeType,
data: file,
filename: file.name
filename: attachment.filename,
size: attachment.size,
type: attachment.type,
createdAt: Date.now(),
textContent: attachment.textContent,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
summary: attachment.summary,
};
await db.attachments.add(attachment);
return attachment;
await db.attachments.add(stored);
return attachment.id;
});
}
/**
* Save multiple attachments at once
* Returns array of attachment IDs
*/
export async function saveAttachments(
messageId: string,
files: File[],
attachments: FileAttachment[]
): Promise<StorageResult<string[]>> {
return withErrorHandling(async () => {
const storedAttachments: StoredAttachment[] = attachments.map((attachment, index) => ({
id: attachment.id,
messageId,
mimeType: attachment.mimeType,
data: files[index],
filename: attachment.filename,
size: attachment.size,
type: attachment.type,
createdAt: Date.now(),
textContent: attachment.textContent,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
summary: attachment.summary,
}));
await db.attachments.bulkAdd(storedAttachments);
return attachments.map(a => a.id);
});
}
@@ -85,7 +143,14 @@ export async function addAttachmentFromBlob(
messageId: string,
data: Blob,
filename: string,
mimeType?: string
type: AttachmentType,
options?: {
mimeType?: string;
textContent?: string;
truncated?: boolean;
analyzed?: boolean;
summary?: string;
}
): Promise<StorageResult<StoredAttachment>> {
return withErrorHandling(async () => {
const id = generateId();
@@ -93,9 +158,16 @@ export async function addAttachmentFromBlob(
const attachment: StoredAttachment = {
id,
messageId,
mimeType: mimeType ?? data.type ?? 'application/octet-stream',
mimeType: options?.mimeType ?? data.type ?? 'application/octet-stream',
data,
filename
filename,
size: data.size,
type,
createdAt: Date.now(),
textContent: options?.textContent,
truncated: options?.truncated,
analyzed: options?.analyzed,
summary: options?.summary,
};
await db.attachments.add(attachment);
@@ -103,6 +175,27 @@ export async function addAttachmentFromBlob(
});
}
// ============================================================================
// Update Operations
// ============================================================================
/**
* Update attachment with analysis results
*/
export async function updateAttachmentAnalysis(
id: string,
analyzed: boolean,
summary?: string
): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.attachments.update(id, { analyzed, summary });
});
}
// ============================================================================
// Delete Operations
// ============================================================================
/**
* Delete an attachment by ID
*/
@@ -121,6 +214,19 @@ export async function deleteAttachmentsForMessage(messageId: string): Promise<St
});
}
/**
* Delete multiple attachments by IDs
*/
export async function deleteAttachmentsByIds(ids: string[]): Promise<StorageResult<void>> {
return withErrorHandling(async () => {
await db.attachments.where('id').anyOf(ids).delete();
});
}
// ============================================================================
// Data Conversion
// ============================================================================
/**
* Get the data URL for an attachment (for displaying images)
*/
@@ -140,6 +246,66 @@ export async function getAttachmentDataUrl(id: string): Promise<StorageResult<st
});
}
/**
* Get base64 data for an image attachment (without data: prefix, for Ollama)
*/
export async function getAttachmentBase64(id: string): Promise<StorageResult<string | null>> {
return withErrorHandling(async () => {
const attachment = await db.attachments.get(id);
if (!attachment || !attachment.mimeType.startsWith('image/')) {
return null;
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Remove the data:image/xxx;base64, prefix
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
resolve(base64);
};
reader.onerror = () => reject(new Error('Failed to read attachment data'));
reader.readAsDataURL(attachment.data);
});
});
}
/**
* Get text content from an attachment (reads from cache or blob)
*/
export async function getAttachmentTextContent(id: string): Promise<StorageResult<string | null>> {
return withErrorHandling(async () => {
const attachment = await db.attachments.get(id);
if (!attachment) {
return null;
}
// Return cached text content if available
if (attachment.textContent) {
return attachment.textContent;
}
// For text files, read from blob
if (attachment.type === 'text' || attachment.mimeType.startsWith('text/')) {
return await attachment.data.text();
}
return null;
});
}
/**
* Create a download URL for an attachment
* Remember to call URL.revokeObjectURL() when done
*/
export function createDownloadUrl(attachment: StoredAttachment): string {
return URL.createObjectURL(attachment.data);
}
// ============================================================================
// Storage Statistics
// ============================================================================
/**
* Get total storage size used by attachments
*/
@@ -171,3 +337,33 @@ export async function getConversationAttachmentSize(
return attachments.reduce((total, a) => total + a.data.size, 0);
});
}
/**
* Get attachment count for a message
*/
export async function getAttachmentCountForMessage(messageId: string): Promise<StorageResult<number>> {
return withErrorHandling(async () => {
return await db.attachments.where('messageId').equals(messageId).count();
});
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Convert StoredAttachment to AttachmentMeta (strips binary data)
*/
function toAttachmentMeta(attachment: StoredAttachment): AttachmentMeta {
return {
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
mimeType: attachment.mimeType,
size: attachment.size,
type: attachment.type,
createdAt: attachment.createdAt,
truncated: attachment.truncated,
analyzed: attachment.analyzed,
};
}

View File

@@ -59,6 +59,8 @@ export interface StoredMessage {
siblingIndex: number;
createdAt: number;
syncVersion?: number;
/** References to attachments stored in the attachments table */
attachmentIds?: string[];
}
/**
@@ -85,6 +87,36 @@ export interface StoredAttachment {
mimeType: string;
data: Blob;
filename: string;
/** File size in bytes */
size: number;
/** Attachment type category */
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
/** Timestamp when attachment was created */
createdAt: number;
/** Cached extracted text (for text/PDF files) */
textContent?: string;
/** Whether the text content was truncated */
truncated?: boolean;
/** Whether this attachment was analyzed by the file analyzer */
analyzed?: boolean;
/** Summary from file analyzer (if analyzed) */
summary?: string;
}
/**
* Attachment metadata (without the binary data)
* Used for displaying attachment info without loading the full blob
*/
export interface AttachmentMeta {
id: string;
messageId: string;
filename: string;
mimeType: string;
size: number;
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
createdAt: number;
truncated?: boolean;
analyzed?: boolean;
}
/**

View File

@@ -48,16 +48,31 @@ export type { MessageSearchResult } from './messages.js';
// Attachment operations
export {
// Query
getAttachmentsForMessage,
getAttachmentMetaForMessage,
getAttachment,
addAttachment,
getAttachmentsByIds,
getAttachmentMetaByIds,
// Create
saveAttachment,
saveAttachments,
addAttachmentFromBlob,
// Update
updateAttachmentAnalysis,
// Delete
deleteAttachment,
deleteAttachmentsForMessage,
deleteAttachmentsByIds,
// Data conversion
getAttachmentDataUrl,
getAttachmentBase64,
getAttachmentTextContent,
createDownloadUrl,
// Statistics
getTotalAttachmentSize,
getConversationAttachmentSize
getConversationAttachmentSize,
getAttachmentCountForMessage
} from './attachments.js';
export type { AttachmentMeta } from './attachments.js';

View File

@@ -18,7 +18,8 @@ function toMessageNode(stored: StoredMessage, childIds: string[]): MessageNode {
role: stored.role,
content: stored.content,
images: stored.images,
toolCalls: stored.toolCalls
toolCalls: stored.toolCalls,
attachmentIds: stored.attachmentIds
},
parentId: stored.parentId,
childIds,
@@ -131,6 +132,7 @@ export async function addMessage(
content: message.content,
images: message.images,
toolCalls: message.toolCalls,
attachmentIds: message.attachmentIds,
siblingIndex,
createdAt: now
};

View File

@@ -149,6 +149,28 @@ export class ChatState {
}
}
/**
* Set the content of the currently streaming message (replaces entirely)
* @param content The new content
*/
setStreamContent(content: string): void {
if (!this.streamingMessageId) return;
this.streamBuffer = content;
const node = this.messageTree.get(this.streamingMessageId);
if (node) {
const updatedNode: MessageNode = {
...node,
message: {
...node.message,
content
}
};
this.messageTree = new Map(this.messageTree).set(this.streamingMessageId, updatedNode);
}
}
/**
* Complete the streaming process
*/
@@ -510,6 +532,41 @@ export class ChatState {
this.streamBuffer = '';
}
/**
* Remove a message from the tree
* Used for temporary messages (like analysis progress indicators)
* @param messageId The message ID to remove
*/
removeMessage(messageId: string): void {
const node = this.messageTree.get(messageId);
if (!node) return;
const newTree = new Map(this.messageTree);
// Remove from parent's childIds
if (node.parentId) {
const parent = newTree.get(node.parentId);
if (parent) {
newTree.set(node.parentId, {
...parent,
childIds: parent.childIds.filter((id) => id !== messageId)
});
}
}
// Update root if this was the root
if (this.rootMessageId === messageId) {
this.rootMessageId = null;
}
// Remove the node
newTree.delete(messageId);
this.messageTree = newTree;
// Remove from active path
this.activePath = this.activePath.filter((id) => id !== messageId);
}
/**
* Load a conversation into the chat state
* @param conversationId The conversation ID

View File

@@ -28,6 +28,16 @@ export interface FileAttachment {
base64Data?: string;
/** Preview thumbnail for images (data URI with prefix for display) */
previewUrl?: string;
/** Whether content was truncated due to size limits */
truncated?: boolean;
/** Original content length before truncation */
originalLength?: number;
/** Original File object for storage (not serializable, transient) */
originalFile?: File;
/** Whether this file was analyzed by the FileAnalyzer agent */
analyzed?: boolean;
/** AI-generated summary from FileAnalyzer (for large/truncated files) */
summary?: string;
}
// ============================================================================
@@ -121,6 +131,16 @@ export const MAX_PDF_SIZE = 10 * 1024 * 1024;
/** Maximum image dimensions (LLaVA limit) */
export const MAX_IMAGE_DIMENSION = 1344;
/** Maximum extracted content length (chars) - prevents context overflow
* 8K chars ≈ 2K tokens per file, allowing ~3 files in an 8K context model
*/
export const MAX_EXTRACTED_CONTENT = 8000;
/** Threshold for auto-analysis of large files (chars)
* Files larger than this would benefit from summarization (future feature)
*/
export const ANALYSIS_THRESHOLD = 8000;
// ============================================================================
// Type Guards
// ============================================================================

View File

@@ -28,6 +28,8 @@ export interface Message {
isSummarized?: boolean;
/** If true, this is a summary message representing compressed conversation history */
isSummary?: boolean;
/** References to attachments stored in IndexedDB */
attachmentIds?: string[];
}
/** A node in the message tree structure (for branching conversations) */

View File

@@ -17,7 +17,8 @@ import {
MAX_IMAGE_SIZE,
MAX_TEXT_SIZE,
MAX_PDF_SIZE,
MAX_IMAGE_DIMENSION
MAX_IMAGE_DIMENSION,
MAX_EXTRACTED_CONTENT
} from '$lib/types/attachment.js';
// ============================================================================
@@ -51,21 +52,74 @@ export function detectFileType(file: File): AttachmentType | null {
return null;
}
// ============================================================================
// Content Truncation
// ============================================================================
/**
* Result of content truncation
*/
interface TruncateResult {
content: string;
truncated: boolean;
originalLength: number;
}
/**
* Truncate content to maximum allowed length
* Tries to truncate at a natural boundary (newline or space)
*/
function truncateContent(content: string, maxLength: number = MAX_EXTRACTED_CONTENT): TruncateResult {
const originalLength = content.length;
if (originalLength <= maxLength) {
return { content, truncated: false, originalLength };
}
// Try to find a natural break point (newline or space) near the limit
let cutPoint = maxLength;
const searchStart = Math.max(0, maxLength - 500);
// Look for last newline before cutoff
const lastNewline = content.lastIndexOf('\n', maxLength);
if (lastNewline > searchStart) {
cutPoint = lastNewline;
} else {
// Look for last space
const lastSpace = content.lastIndexOf(' ', maxLength);
if (lastSpace > searchStart) {
cutPoint = lastSpace;
}
}
const truncatedContent = content.slice(0, cutPoint) +
`\n\n[... content truncated: ${formatFileSize(originalLength)} total, showing first ${formatFileSize(cutPoint)} ...]`;
return {
content: truncatedContent,
truncated: true,
originalLength
};
}
// ============================================================================
// Text File Processing
// ============================================================================
/**
* Read a text file and return its content
* Read a text file and return its content with truncation info
*/
export async function readTextFile(file: File): Promise<string> {
export async function readTextFile(file: File): Promise<TruncateResult> {
if (file.size > MAX_TEXT_SIZE) {
throw new Error(`File too large. Maximum size is ${MAX_TEXT_SIZE / 1024 / 1024}MB`);
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onload = () => {
const rawContent = reader.result as string;
resolve(truncateContent(rawContent));
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
@@ -150,8 +204,18 @@ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
try {
pdfjsLib = await import('pdfjs-dist');
// Set worker source using CDN for reliability
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
// Use locally bundled worker (copied to static/ during build)
// Falls back to CDN if local worker isn't available
const localWorkerPath = '/pdf.worker.min.mjs';
const cdnWorkerPath = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
// Try local first, with CDN fallback
try {
const response = await fetch(localWorkerPath, { method: 'HEAD' });
pdfjsLib.GlobalWorkerOptions.workerSrc = response.ok ? localWorkerPath : cdnWorkerPath;
} catch {
pdfjsLib.GlobalWorkerOptions.workerSrc = cdnWorkerPath;
}
return pdfjsLib;
} catch (error) {
@@ -160,9 +224,9 @@ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
}
/**
* Extract text content from a PDF file
* Extract text content from a PDF file with error handling and content limits
*/
export async function extractPdfText(file: File): Promise<string> {
export async function extractPdfText(file: File): Promise<TruncateResult> {
if (file.size > MAX_PDF_SIZE) {
throw new Error(`PDF too large. Maximum size is ${MAX_PDF_SIZE / 1024 / 1024}MB`);
}
@@ -173,20 +237,63 @@ export async function extractPdfText(file: File): Promise<string> {
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
const textParts: string[] = [];
let totalChars = 0;
let stoppedEarly = false;
const failedPages: number[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
'str' in item
)
.map((item) => item.str)
.join(' ');
textParts.push(pageText);
// Stop if we've already collected enough content
if (totalChars >= MAX_EXTRACTED_CONTENT) {
stoppedEarly = true;
break;
}
try {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
// Null check for textContent.items
if (!textContent?.items) {
console.warn(`PDF page ${i}: No text content items`);
failedPages.push(i);
continue;
}
const pageText = textContent.items
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
'str' in item && typeof item.str === 'string'
)
.map((item) => item.str)
.join(' ')
.trim();
if (pageText) {
textParts.push(pageText);
totalChars += pageText.length;
}
} catch (pageError) {
console.warn(`PDF page ${i} extraction failed:`, pageError);
failedPages.push(i);
// Continue with other pages instead of failing entirely
}
}
return textParts.join('\n\n');
let rawContent = textParts.join('\n\n');
// Add metadata about extraction issues
const metadata: string[] = [];
if (failedPages.length > 0) {
metadata.push(`[Note: Failed to extract pages: ${failedPages.join(', ')}]`);
}
if (stoppedEarly) {
metadata.push(`[Note: Extraction stopped at page ${textParts.length} of ${pdf.numPages} due to content limit]`);
}
if (metadata.length > 0) {
rawContent = metadata.join('\n') + '\n\n' + rawContent;
}
return truncateContent(rawContent);
}
// ============================================================================
@@ -226,29 +333,36 @@ export async function processFile(file: File): Promise<ProcessFileOutcome> {
attachment: {
...baseAttachment,
base64Data: base64,
previewUrl
previewUrl,
originalFile: file
}
};
}
case 'text': {
const textContent = await readTextFile(file);
const result = await readTextFile(file);
return {
success: true,
attachment: {
...baseAttachment,
textContent
textContent: result.content,
truncated: result.truncated,
originalLength: result.originalLength,
originalFile: file
}
};
}
case 'pdf': {
const textContent = await extractPdfText(file);
const result = await extractPdfText(file);
return {
success: true,
attachment: {
...baseAttachment,
textContent
textContent: result.content,
truncated: result.truncated,
originalLength: result.originalLength,
originalFile: file
}
};
}
@@ -302,11 +416,27 @@ export function getFileIcon(type: AttachmentType): string {
/**
* Format attachment content for inclusion in message
* Prepends file content with a header showing filename
* Uses XML-style tags for cleaner parsing by LLMs
*/
export function formatAttachmentsForMessage(attachments: FileAttachment[]): string {
return attachments
.filter((a) => a.textContent)
.map((a) => `--- ${a.filename} ---\n${a.textContent}`)
.map((a) => {
const truncatedAttr = a.truncated ? ' truncated="true"' : '';
const sizeAttr = ` size="${formatFileSize(a.size)}"`;
return `<file name="${escapeXmlAttr(a.filename)}"${sizeAttr}${truncatedAttr}>\n${a.textContent}\n</file>`;
})
.join('\n\n');
}
/**
* Escape special characters for XML attribute values
*/
function escapeXmlAttr(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

View File

@@ -10,13 +10,16 @@
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
import { settingsState } from '$lib/stores/settings.svelte';
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation } from '$lib/storage';
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation, saveAttachments } from '$lib/storage';
import { ollamaClient } from '$lib/ollama';
import type { OllamaMessage, OllamaToolDefinition, OllamaToolCall } from '$lib/ollama';
import { getFunctionModel, USE_FUNCTION_MODEL, runToolCalls, formatToolResultsForChat } from '$lib/tools';
import { searchSimilar, formatResultsAsContext, getKnowledgeBaseStats } from '$lib/memory';
import ChatWindow from '$lib/components/chat/ChatWindow.svelte';
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);
@@ -25,6 +28,10 @@
// Thinking mode state (for reasoning models)
let thinkingEnabled = $state(true);
// File analysis state
let isAnalyzingFiles = $state(false);
let analyzingFileNames = $state<string[]>([]);
// Derived: Check if selected model supports thinking
const supportsThinking = $derived.by(() => {
const caps = modelsState.selectedCapabilities;
@@ -70,7 +77,7 @@
* Handle first message submission
* Creates a new conversation and starts streaming the response
*/
async function handleFirstMessage(content: string, images?: string[]): Promise<void> {
async function handleFirstMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
const model = modelsState.selectedId;
if (!model) {
console.error('No model selected');
@@ -104,21 +111,136 @@
// Set up chat state for the new conversation
chatState.conversationId = conversationId;
// Add user message to tree
// Collect attachment IDs if we have attachments to save
let attachmentIds: string[] | undefined;
if (attachments && attachments.length > 0) {
attachmentIds = attachments.map(a => a.id);
}
// Add user message to tree (including attachmentIds for display)
const userMessageId = chatState.addMessage({
role: 'user',
content,
images
images,
attachmentIds
});
// Save attachments to IndexedDB
if (attachments && attachments.length > 0) {
const files = await Promise.all(attachments.map(async (a) => {
if (a.base64Data) {
const binary = atob(a.base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new File([bytes], a.filename, { type: a.mimeType });
} else if (a.textContent) {
return new File([a.textContent], a.filename, { type: a.mimeType });
} else {
return new File([], a.filename, { type: a.mimeType });
}
}));
const saveResult = await saveAttachments(userMessageId, files, attachments);
if (!saveResult.success) {
console.error('Failed to save attachments:', saveResult.error);
}
}
// Persist user message to IndexedDB with the SAME ID as chatState
await addStoredMessage(conversationId, { role: 'user', content, images }, null, userMessageId);
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
// Start streaming response
const assistantMessageId = chatState.startStreaming();
// Process attachments if any
let contentForOllama = content;
let assistantMessageId: string | null = null;
if (attachments && attachments.length > 0) {
// Show processing indicator - this message will become the assistant response
isAnalyzingFiles = true;
analyzingFileNames = attachments.map(a => a.filename);
assistantMessageId = chatState.startStreaming();
const fileCount = attachments.length;
const fileLabel = fileCount === 1 ? 'file' : 'files';
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
try {
// Check if any files need actual LLM analysis
// 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, 3);
// Update attachments with results
filesToAnalyze.forEach((file) => {
const result = analysisResults.get(file.id);
if (result) {
file.analyzed = result.analyzed;
file.summary = result.summary;
}
});
// Build formatted content with file summaries
const formattedParts: string[] = [content];
for (const attachment of attachments) {
const result = analysisResults.get(attachment.id);
if (result) {
formattedParts.push(formatAnalyzedAttachment(attachment, result));
} else if (attachment.textContent) {
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
}
}
contentForOllama = formattedParts.join('\n\n');
} else {
// No files need analysis, format with content
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
}
// Keep "Processing..." visible - LLM streaming will replace it
} catch (error) {
console.error('[NewChat] File processing failed:', error);
chatState.setStreamContent('Processing failed, proceeding with original content...');
await new Promise(r => setTimeout(r, 1000));
// Fallback: use original content with raw file text
const parts: string[] = [content];
for (const a of attachments) {
if (a.textContent) {
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
}
}
contentForOllama = parts.join('\n\n');
} finally {
isAnalyzingFiles = false;
analyzingFileNames = [];
}
}
// Start streaming response (reuse existing message if processing files)
const hadProcessingMessage = !!assistantMessageId;
if (!assistantMessageId) {
assistantMessageId = chatState.startStreaming();
}
// Track if we need to clear the "Processing..." text on first token
let needsClearOnFirstToken = hadProcessingMessage;
// Start streaming metrics tracking
streamingMetricsState.startStream();
@@ -129,7 +251,7 @@
try {
let messages: OllamaMessage[] = [{
role: 'user',
content,
content: contentForOllama,
images
}];
@@ -148,6 +270,9 @@
systemParts.push(`You have access to a knowledge base. Use the following relevant context to help answer the user's question. If the context isn't relevant, you can ignore it.\n\n${ragContext}`);
}
// Always add language instruction
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
// Inject combined system message
if (systemParts.length > 0) {
const systemMessage: OllamaMessage = {
@@ -175,6 +300,11 @@
{ model: chatModel, messages, tools, think: useNativeThinking, options: settingsState.apiParameters },
{
onThinkingToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Accumulate thinking and update the message
if (!streamingThinking) {
// Start the thinking block
@@ -185,6 +315,11 @@
streamingMetricsState.incrementTokens();
},
onToken: (token) => {
// Clear "Processing..." on first token
if (needsClearOnFirstToken) {
chatState.setStreamContent('');
needsClearOnFirstToken = false;
}
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\n\n');
@@ -232,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();
}
@@ -243,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();
}