From 463067d2aecd53c49903194889063ba2291dbb0e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 1 Jan 2026 04:36:18 +0100 Subject: [PATCH] feat: add context management, summarization, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context Window Management: - Add ContextFullModal with recovery options (summarize, new chat, dismiss) - Show toast notifications at 85% and 95% context thresholds - Block sending when context exceeds 100% until user takes action Conversation Summarization: - Add isSummarized/isSummary flags to Message type - Implement markAsSummarized() and insertSummaryMessage() in ChatState - Add messagesForContext derived state (excludes summarized, includes summaries) - Complete handleSummarize flow with LLM summary generation - Add amber-styled summary message UI with archive icon Auto-scroll Fixes: - Fix Svelte 5 reactivity issues by using plain variables instead of $state - Add continuous scroll during streaming via streamBuffer tracking - Properly handle user scroll override (re-enable at bottom) Drag & Drop Improvements: - Add full-screen drag overlay with document-level event listeners - Use dragCounter pattern for reliable nested element detection - Add hideDropZone prop to FileUpload/ImageUpload components Additional Features: - Add SystemPromptSelector for per-conversation prompts - Add SearchModal for full-text message search - Add ShortcutsModal for keyboard shortcuts help - Add theme toggle to TopNav 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/lib/components/chat/ChatInput.svelte | 146 ++++++- .../src/lib/components/chat/ChatWindow.svelte | 213 +++++++++-- .../components/chat/ContextFullModal.svelte | 141 +++++++ .../src/lib/components/chat/FileUpload.svelte | 106 +++++- .../lib/components/chat/ImageUpload.svelte | 62 ++- .../lib/components/chat/MessageItem.svelte | 32 ++ .../lib/components/chat/MessageList.svelte | 25 +- .../chat/SystemPromptSelector.svelte | 206 ++++++++++ frontend/src/lib/components/chat/index.ts | 8 + .../src/lib/components/layout/TopNav.svelte | 44 ++- .../lib/components/shared/SearchModal.svelte | 360 ++++++++++++++++++ .../components/shared/ShortcutsModal.svelte | 125 ++++++ frontend/src/lib/components/shared/index.ts | 2 + frontend/src/lib/memory/summarizer.ts | 20 +- frontend/src/lib/storage/conversations.ts | 19 +- frontend/src/lib/storage/db.ts | 17 + frontend/src/lib/storage/index.ts | 5 +- frontend/src/lib/storage/messages.ts | 65 ++++ frontend/src/lib/stores/chat.svelte.ts | 141 ++++++- .../src/lib/stores/conversations.svelte.ts | 9 + frontend/src/lib/stores/models.svelte.ts | 9 +- frontend/src/lib/types/chat.ts | 4 + frontend/src/lib/types/conversation.ts | 2 + frontend/src/lib/utils/keyboard.ts | 12 + frontend/src/routes/+layout.svelte | 42 +- 25 files changed, 1703 insertions(+), 112 deletions(-) create mode 100644 frontend/src/lib/components/chat/ContextFullModal.svelte create mode 100644 frontend/src/lib/components/chat/SystemPromptSelector.svelte create mode 100644 frontend/src/lib/components/shared/SearchModal.svelte create mode 100644 frontend/src/lib/components/shared/ShortcutsModal.svelte diff --git a/frontend/src/lib/components/chat/ChatInput.svelte b/frontend/src/lib/components/chat/ChatInput.svelte index 2917150..ad33380 100644 --- a/frontend/src/lib/components/chat/ChatInput.svelte +++ b/frontend/src/lib/components/chat/ChatInput.svelte @@ -2,11 +2,14 @@ /** * ChatInput - Message input area with auto-growing textarea * Handles send/stop actions, keyboard shortcuts, and file uploads + * Drag overlay appears when files are dragged over the input area */ import { modelsState } from '$lib/stores'; import type { FileAttachment } from '$lib/types/attachment.js'; - import { formatAttachmentsForMessage } from '$lib/utils/file-processor.js'; + import { formatAttachmentsForMessage, 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 { @@ -35,6 +38,10 @@ // File attachment state (text/PDF for all models) let pendingAttachments = $state([]); + // Drag overlay state + let isDragOver = $state(false); + let dragCounter = 0; // Track enter/leave for nested elements + // Derived state const hasContent = $derived( inputValue.trim().length > 0 || pendingImages.length > 0 || pendingAttachments.length > 0 @@ -45,6 +52,12 @@ // Vision model detection const isVisionModel = $derived(modelsState.selectedSupportsVision); + // Token estimation for current input + const tokenEstimate = $derived( + estimateMessageTokens(inputValue, pendingImages.length > 0 ? pendingImages : undefined) + ); + const showTokenCount = $derived(inputValue.length > 0 || pendingImages.length > 0); + /** * Auto-resize textarea based on content */ @@ -144,9 +157,132 @@ $effect(() => { focusInput(); }); + + // ========================================================================= + // Drag & Drop - Document-level listeners for reliable detection + // ========================================================================= + + // Set up document-level drag listeners for reliable detection + $effect(() => { + if (disabled) return; + + function onDragEnter(event: DragEvent): void { + if (!event.dataTransfer?.types.includes('Files')) return; + event.preventDefault(); + dragCounter++; + isDragOver = true; + } + + function onDragLeave(event: DragEvent): void { + event.preventDefault(); + dragCounter--; + if (dragCounter <= 0) { + dragCounter = 0; + isDragOver = false; + } + } + + function onDragOver(event: DragEvent): void { + if (!event.dataTransfer?.types.includes('Files')) return; + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + } + + function onDrop(event: DragEvent): void { + event.preventDefault(); + isDragOver = false; + dragCounter = 0; + + if (!event.dataTransfer?.files.length) return; + const files = Array.from(event.dataTransfer.files); + processDroppedFiles(files); + } + + document.addEventListener('dragenter', onDragEnter); + document.addEventListener('dragleave', onDragLeave); + document.addEventListener('dragover', onDragOver); + document.addEventListener('drop', onDrop); + + return () => { + document.removeEventListener('dragenter', onDragEnter); + document.removeEventListener('dragleave', onDragLeave); + document.removeEventListener('dragover', onDragOver); + document.removeEventListener('drop', onDrop); + }; + }); + + /** + * Process dropped files - images go to pendingImages, others to attachments + */ + async function processDroppedFiles(files: File[]): Promise { + const imageFiles: File[] = []; + const otherFiles: File[] = []; + + for (const file of files) { + if (isImageMimeType(file.type)) { + imageFiles.push(file); + } else { + otherFiles.push(file); + } + } + + // Process images if vision is supported + if (imageFiles.length > 0 && isVisionModel) { + const { processImageForOllama, isValidImageType } = await import('$lib/ollama/image-processor'); + const validImages = imageFiles.filter(isValidImageType); + const maxImages = 4; + const remainingSlots = maxImages - pendingImages.length; + const toProcess = validImages.slice(0, remainingSlots); + + for (const file of toProcess) { + try { + const processed = await processImageForOllama(file); + pendingImages = [...pendingImages, processed.base64]; + } catch (err) { + console.error('Failed to process image:', err); + } + } + } + + // Process other files + for (const file of otherFiles) { + const result = await processFile(file); + if (result.success) { + pendingAttachments = [...pendingAttachments, result.attachment]; + } + } + } + +{#if isDragOver} +
+
+ + + + + {#if isVisionModel} + Drop images or files + {:else} + Drop files here + {/if} + + + {#if isVisionModel} + Images, text files, and PDFs supported + {:else} + Text files and PDFs supported + {/if} + +
+
+{/if} +
+
@@ -269,5 +407,11 @@ + {/if} files supported + {#if showTokenCount} + · + + ~{formatTokenCount(tokenEstimate.totalTokens)} tokens + + {/if}

diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index b3d0f77..237437b 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -26,8 +26,10 @@ import ChatInput from './ChatInput.svelte'; import EmptyState from './EmptyState.svelte'; import ContextUsageBar from './ContextUsageBar.svelte'; + import ContextFullModal from './ContextFullModal.svelte'; import SummaryBanner from './SummaryBanner.svelte'; import StreamingStats from './StreamingStats.svelte'; + import SystemPromptSelector from './SystemPromptSelector.svelte'; import ModelParametersPanel from '$lib/components/settings/ModelParametersPanel.svelte'; import { settingsState } from '$lib/stores/settings.svelte'; @@ -58,6 +60,10 @@ // Summarization state let isSummarizing = $state(false); + // Context full modal state + let showContextFullModal = $state(false); + let pendingMessage: { content: string; images?: string[] } | null = $state(null); + // Tool execution state let isExecutingTools = $state(false); @@ -77,6 +83,35 @@ checkKnowledgeBase(); }); + // Track previous context state for threshold crossing detection + let previousContextState: 'normal' | 'warning' | 'critical' | 'full' = 'normal'; + + // Context warning toasts - show once per threshold crossing + $effect(() => { + const percentage = contextManager.contextUsage.percentage; + let currentState: 'normal' | 'warning' | 'critical' | 'full' = 'normal'; + + if (percentage >= 100) { + currentState = 'full'; + } else if (percentage >= 95) { + currentState = 'critical'; + } else if (percentage >= 85) { + currentState = 'warning'; + } + + // Only show toast when crossing INTO a worse state + if (currentState !== previousContextState) { + if (currentState === 'warning' && previousContextState === 'normal') { + toastState.warning('Context is 85% full. Consider starting a new chat soon.'); + } else if (currentState === 'critical' && previousContextState !== 'full') { + toastState.warning('Context almost full (95%). Summarize or start a new chat.'); + } else if (currentState === 'full') { + // Full state is handled by the modal, no toast needed + } + previousContextState = currentState; + } + }); + /** * Check if knowledge base has any documents */ @@ -165,10 +200,10 @@ /** * Convert chat state messages to Ollama API format - * Uses allMessages to include hidden tool result messages + * Uses messagesForContext to exclude summarized originals but include summaries */ function getMessagesForApi(): OllamaMessage[] { - return chatState.allMessages.map((node) => ({ + return chatState.messagesForContext.map((node) => ({ role: node.message.role as OllamaMessage['role'], content: node.message.content, images: node.message.images @@ -186,6 +221,7 @@ const { toSummarize, toKeep } = selectMessagesForSummarization(messages, 0); if (toSummarize.length === 0) { + toastState.warning('No messages available to summarize'); return; } @@ -194,24 +230,88 @@ try { // Generate summary using the LLM const summary = await generateSummary(toSummarize, selectedModel); - const formattedSummary = formatSummaryAsContext(summary); - // Calculate savings - const savedTokens = calculateTokenSavings(toSummarize, formattedSummary); + // Calculate savings for logging + const savedTokens = calculateTokenSavings(toSummarize, summary); - // TODO: Implement message replacement in chat state - // This requires adding a method to ChatState to replace messages - // with a summary node + // Mark original messages as summarized (they'll be hidden from UI and context) + const messageIdsToSummarize = toSummarize.map((node) => node.id); + chatState.markAsSummarized(messageIdsToSummarize); + // Insert the summary message at the beginning (after any system messages) + chatState.insertSummaryMessage(summary); + + // Force context recalculation with updated message list + contextManager.updateMessages(chatState.visibleMessages, true); + + // Show success notification + toastState.success( + `Summarized ${toSummarize.length} messages, saved ~${Math.round(savedTokens / 100) * 100} tokens` + ); } catch (error) { console.error('Summarization failed:', error); + toastState.error('Summarization failed. Please try again.'); } finally { isSummarizing = false; } } + // ========================================================================= + // Context Full Modal Handlers + // ========================================================================= + /** - * Send a message and stream the response (with tool support) + * Handle "Summarize & Continue" from context full modal + */ + async function handleContextFullSummarize(): Promise { + showContextFullModal = false; + await handleSummarize(); + + // After summarization, try to send the pending message + if (pendingMessage && contextManager.contextUsage.percentage < 100) { + const { content, images } = pendingMessage; + pendingMessage = null; + await handleSendMessage(content, images); + } else if (pendingMessage) { + // Still full after summarization - show toast + toastState.warning('Context still full after summarization. Try starting a new chat.'); + pendingMessage = null; + } + } + + /** + * Handle "Start New Chat" from context full modal + */ + function handleContextFullNewChat(): void { + showContextFullModal = false; + pendingMessage = null; + chatState.reset(); + contextManager.reset(); + toastState.info('Started new chat. Previous conversation was saved.'); + } + + /** + * Handle "Continue Anyway" from context full modal + */ + async function handleContextFullDismiss(): Promise { + showContextFullModal = false; + + // Try to send the message anyway (may fail or get truncated) + if (pendingMessage) { + const { content, images } = pendingMessage; + pendingMessage = null; + // Bypass the context check by calling the inner logic directly + await sendMessageInternal(content, images); + } + } + + /** + * Check if summarization is possible (enough messages) + */ + const canSummarizeConversation = $derived(chatState.visibleMessages.length >= 6); + + /** + * Send a message - checks context and may show modal */ async function handleSendMessage(content: string, images?: string[]): Promise { const selectedModel = modelsState.selectedId; @@ -221,6 +321,24 @@ return; } + // Check if context is full (100%+) + if (contextManager.contextUsage.percentage >= 100) { + // Store pending message and show modal + pendingMessage = { content, images }; + showContextFullModal = true; + return; + } + + await sendMessageInternal(content, images); + } + + /** + * Internal: Send message and stream response (bypasses context check) + */ + async function sendMessageInternal(content: string, images?: string[]): Promise { + 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); @@ -289,11 +407,24 @@ // Build system prompt from active prompt + thinking + RAG context const systemParts: string[] = []; - // Wait for prompts to be loaded, then add system prompt if active + // Wait for prompts to be loaded await promptsState.ready(); - const activePrompt = promptsState.activePrompt; - if (activePrompt) { - systemParts.push(activePrompt.content); + + // Priority: per-conversation prompt > global active prompt > none + let promptContent: string | null = null; + if (conversation?.systemPromptId) { + // Use per-conversation prompt + const conversationPrompt = promptsState.get(conversation.systemPromptId); + if (conversationPrompt) { + promptContent = conversationPrompt.content; + } + } else if (promptsState.activePrompt) { + // Fall back to global active prompt + promptContent = promptsState.activePrompt.content; + } + + if (promptContent) { + systemParts.push(promptContent); } // RAG: Retrieve relevant context for the last user message @@ -703,26 +834,36 @@
- +
- - + + + {#if mode === 'conversation' && conversation} + {/if} - +
{#if supportsThinking} @@ -762,3 +903,13 @@ + + + diff --git a/frontend/src/lib/components/chat/ContextFullModal.svelte b/frontend/src/lib/components/chat/ContextFullModal.svelte new file mode 100644 index 0000000..69b8b51 --- /dev/null +++ b/frontend/src/lib/components/chat/ContextFullModal.svelte @@ -0,0 +1,141 @@ + + +{#if isOpen} + +
+ +
+ +
+
+ + + +
+
+

Context Window Full

+

+ {formatTokenCount(usage.usedTokens)} / {formatContextSize(usage.maxTokens)} tokens used +

+
+
+ + +
+

+ The conversation has exceeded the model's context window by + {formatTokenCount(overflowAmount)} tokens. +

+

+ The model cannot process more text until space is freed. Choose how to proceed: +

+
+ + +
+ + {#if canSummarize} + + {/if} + + + + + + +
+
+
+{/if} diff --git a/frontend/src/lib/components/chat/FileUpload.svelte b/frontend/src/lib/components/chat/FileUpload.svelte index e416e37..93d4532 100644 --- a/frontend/src/lib/components/chat/FileUpload.svelte +++ b/frontend/src/lib/components/chat/FileUpload.svelte @@ -22,6 +22,8 @@ supportsVision?: boolean; /** Whether upload is disabled */ disabled?: boolean; + /** Hide the drop zone (when drag overlay is handled by parent) */ + hideDropZone?: boolean; } const { @@ -30,7 +32,8 @@ attachments, onAttachmentsChange, supportsVision = false, - disabled = false + disabled = false, + hideDropZone = false }: Props = $props(); // Processing state @@ -112,7 +115,9 @@ } /** - * Handle paste events for file attachments + * Handle paste events for file attachments and images + * Images are forwarded to ImageUpload when vision is supported, + * otherwise shows a helpful message */ function handlePaste(event: ClipboardEvent) { if (disabled) return; @@ -121,22 +126,101 @@ if (!items) return; const files: File[] = []; + const imageFiles: File[] = []; for (const item of items) { - // Handle non-image files (images handled by ImageUpload) - if (!item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - files.push(file); - } + const file = item.getAsFile(); + if (!file) continue; + + if (item.type.startsWith('image/')) { + imageFiles.push(file); + } else { + files.push(file); } } + // Handle non-image files if (files.length > 0) { - // Don't prevent default if we have no files to process - // (let ImageUpload handle images) processFiles(files); } + + // Handle image files + if (imageFiles.length > 0) { + event.preventDefault(); + + if (supportsVision) { + // Forward to ImageUpload by triggering file processing there + // ImageUpload has its own paste handler, but we handle it here too + // to ensure consistent behavior + processImageFiles(imageFiles); + } else { + // Show message that images require vision model + errorMessage = 'Images can only be used with vision-capable models (e.g., llava, bakllava)'; + setTimeout(() => { + errorMessage = null; + }, 5000); + } + } + } + + /** + * Process image files and add to images array + */ + async function processImageFiles(files: File[]) { + // Import the processor dynamically to keep bundle small when not needed + const { processImageForOllama, isValidImageType, ImageProcessingError } = await import( + '$lib/ollama/image-processor' + ); + + const validFiles = files.filter(isValidImageType); + if (validFiles.length === 0) { + errorMessage = 'No valid image files. Supported: JPEG, PNG, GIF, WebP'; + setTimeout(() => { + errorMessage = null; + }, 3000); + return; + } + + // Limit to 4 images max + const maxImages = 4; + const remainingSlots = maxImages - images.length; + const filesToProcess = validFiles.slice(0, remainingSlots); + + if (filesToProcess.length === 0) { + errorMessage = `Maximum ${maxImages} images allowed`; + setTimeout(() => { + errorMessage = null; + }, 3000); + return; + } + + isProcessing = true; + errorMessage = null; + + try { + const newImages: string[] = []; + + for (const file of filesToProcess) { + try { + const processed = await processImageForOllama(file); + newImages.push(processed.base64); + } catch (err) { + if (err instanceof ImageProcessingError) { + console.error(`Failed to process ${file.name}:`, err.message); + errorMessage = err.message; + } else { + console.error(`Failed to process ${file.name}:`, err); + errorMessage = `Failed to process ${file.name}`; + } + } + } + + if (newImages.length > 0) { + onImagesChange([...images, ...newImages]); + } + } finally { + isProcessing = false; + } } // Set up paste listener @@ -153,7 +237,7 @@
{#if supportsVision} - + {/if} diff --git a/frontend/src/lib/components/chat/ImageUpload.svelte b/frontend/src/lib/components/chat/ImageUpload.svelte index f34ee12..5ee2dd1 100644 --- a/frontend/src/lib/components/chat/ImageUpload.svelte +++ b/frontend/src/lib/components/chat/ImageUpload.svelte @@ -1,7 +1,8 @@ + +{#if hideDropZone} + {#if images.length > 0} +
+ {#each images as image, index (index)} + removeImage(index)} + alt={`Uploaded image ${index + 1}`} + /> + {/each} +
+ {/if} +{:else}
{#if images.length > 0} @@ -287,7 +272,7 @@ {#if isDragOver} Drop images here {:else} - Drag & drop, click, or paste images + Drag & drop or click to add images {/if}

@@ -308,3 +293,4 @@
{/if}
+{/if} diff --git a/frontend/src/lib/components/chat/MessageItem.svelte b/frontend/src/lib/components/chat/MessageItem.svelte index 338050c..3b7495b 100644 --- a/frontend/src/lib/components/chat/MessageItem.svelte +++ b/frontend/src/lib/components/chat/MessageItem.svelte @@ -40,9 +40,13 @@ const isUser = $derived(node.message.role === 'user'); const isAssistant = $derived(node.message.role === 'assistant'); + 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); + // Detect summary messages (compressed conversation history) + const isSummaryMessage = $derived(node.message.isSummary === true); + // Detect tool result messages (sent as user role but should be hidden or styled differently) const isToolResultMessage = $derived( isUser && ( @@ -104,6 +108,34 @@ {#if isToolResultMessage} +{:else if isSummaryMessage} + +
+
+ +
+ + + +
+
+
+ Conversation Summary + Earlier messages compressed +
+
+ +
+
+
+
{:else}
{ const isStreaming = chatState.isStreaming; @@ -95,8 +96,22 @@ wasStreaming = isStreaming; }); + // Continuous scroll during streaming as content grows + $effect(() => { + // Track stream buffer changes - when content grows during streaming, scroll + const buffer = chatState.streamBuffer; + const isStreaming = chatState.isStreaming; + + if (isStreaming && buffer && !userScrolledAway) { + requestAnimationFrame(() => { + scrollToBottomInstant(); + }); + } + }); + // Scroll when new messages are added (user sends a message) - let previousMessageCount = $state(0); + // Note: Using plain variable to avoid creating a dependency that re-triggers the effect + let previousMessageCount = 0; $effect(() => { const currentCount = chatState.visibleMessages.length; diff --git a/frontend/src/lib/components/chat/SystemPromptSelector.svelte b/frontend/src/lib/components/chat/SystemPromptSelector.svelte new file mode 100644 index 0000000..53843e7 --- /dev/null +++ b/frontend/src/lib/components/chat/SystemPromptSelector.svelte @@ -0,0 +1,206 @@ + + + + +
+ + + + + {#if isOpen} +
+ + + + {#if prompts.length > 0} +
+ + + {#each prompts as prompt} + + {/each} + {:else} +
+ No prompts available. Create one +
+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/chat/index.ts b/frontend/src/lib/components/chat/index.ts index 8ff1cbb..0b655b9 100644 --- a/frontend/src/lib/components/chat/index.ts +++ b/frontend/src/lib/components/chat/index.ts @@ -30,3 +30,11 @@ export { default as CodeBlock } from './CodeBlock.svelte'; // Indicators and states export { default as StreamingIndicator } from './StreamingIndicator.svelte'; export { default as EmptyState } from './EmptyState.svelte'; + +// Context management +export { default as ContextUsageBar } from './ContextUsageBar.svelte'; +export { default as ContextFullModal } from './ContextFullModal.svelte'; +export { default as SummaryBanner } from './SummaryBanner.svelte'; + +// Prompt selection +export { default as SystemPromptSelector } from './SystemPromptSelector.svelte'; diff --git a/frontend/src/lib/components/layout/TopNav.svelte b/frontend/src/lib/components/layout/TopNav.svelte index 4047fb0..5c8341c 100644 --- a/frontend/src/lib/components/layout/TopNav.svelte +++ b/frontend/src/lib/components/layout/TopNav.svelte @@ -167,8 +167,50 @@ {/if} - +
+ + {#if isInChat} diff --git a/frontend/src/lib/components/shared/SearchModal.svelte b/frontend/src/lib/components/shared/SearchModal.svelte new file mode 100644 index 0000000..3542eda --- /dev/null +++ b/frontend/src/lib/components/shared/SearchModal.svelte @@ -0,0 +1,360 @@ + + + + +{#if isOpen} + + +{/if} diff --git a/frontend/src/lib/components/shared/ShortcutsModal.svelte b/frontend/src/lib/components/shared/ShortcutsModal.svelte new file mode 100644 index 0000000..ed55ee8 --- /dev/null +++ b/frontend/src/lib/components/shared/ShortcutsModal.svelte @@ -0,0 +1,125 @@ + + + + +{#if isOpen} + + +{/if} diff --git a/frontend/src/lib/components/shared/index.ts b/frontend/src/lib/components/shared/index.ts index 5a65c23..ef7ee21 100644 --- a/frontend/src/lib/components/shared/index.ts +++ b/frontend/src/lib/components/shared/index.ts @@ -11,3 +11,5 @@ export { default as Skeleton } from './Skeleton.svelte'; export { default as MessageSkeleton } from './MessageSkeleton.svelte'; export { default as ErrorBoundary } from './ErrorBoundary.svelte'; export { default as SettingsModal } from './SettingsModal.svelte'; +export { default as ShortcutsModal } from './ShortcutsModal.svelte'; +export { default as SearchModal } from './SearchModal.svelte'; diff --git a/frontend/src/lib/memory/summarizer.ts b/frontend/src/lib/memory/summarizer.ts index 890af67..570ed14 100644 --- a/frontend/src/lib/memory/summarizer.ts +++ b/frontend/src/lib/memory/summarizer.ts @@ -22,8 +22,11 @@ Keep the summary brief but complete. Write in third person. Conversation: `; -/** Minimum messages to consider for summarization */ -const MIN_MESSAGES_FOR_SUMMARY = 6; +/** Minimum messages required in the toSummarize array (after filtering) */ +const MIN_MESSAGES_TO_SUMMARIZE = 2; + +/** Minimum total messages before showing summarization option */ +const MIN_TOTAL_MESSAGES_FOR_SUMMARY = 6; /** How many recent messages to always preserve */ const PRESERVE_RECENT_MESSAGES = 4; @@ -54,7 +57,7 @@ export async function generateSummary( messages: MessageNode[], model: string ): Promise { - if (messages.length < MIN_MESSAGES_FOR_SUMMARY) { + if (messages.length < MIN_MESSAGES_TO_SUMMARIZE) { throw new Error('Not enough messages to summarize'); } @@ -151,8 +154,15 @@ export function shouldSummarize( maxTokens: number, messageCount: number ): boolean { - // Don't summarize if too few messages - if (messageCount < MIN_MESSAGES_FOR_SUMMARY) { + // Don't show summarization if not enough total messages + if (messageCount < MIN_TOTAL_MESSAGES_FOR_SUMMARY) { + return false; + } + + // Check if there would actually be messages to summarize after filtering + // (messageCount - PRESERVE_RECENT_MESSAGES = messages available for summarization) + const summarizableCount = messageCount - PRESERVE_RECENT_MESSAGES; + if (summarizableCount < MIN_MESSAGES_TO_SUMMARIZE) { return false; } diff --git a/frontend/src/lib/storage/conversations.ts b/frontend/src/lib/storage/conversations.ts index c9bcb6e..e86ed0e 100644 --- a/frontend/src/lib/storage/conversations.ts +++ b/frontend/src/lib/storage/conversations.ts @@ -20,7 +20,8 @@ function toDomainConversation(stored: StoredConversation): Conversation { updatedAt: new Date(stored.updatedAt), isPinned: stored.isPinned, isArchived: stored.isArchived, - messageCount: stored.messageCount + messageCount: stored.messageCount, + systemPromptId: stored.systemPromptId ?? null }; } @@ -42,7 +43,8 @@ function toStoredConversation( updatedAt: conversation.updatedAt?.getTime() ?? now, isPinned: conversation.isPinned, isArchived: conversation.isArchived, - messageCount: conversation.messageCount + messageCount: conversation.messageCount, + systemPromptId: conversation.systemPromptId ?? null }; } @@ -139,7 +141,8 @@ export async function createConversation( isPinned: data.isPinned ?? false, isArchived: data.isArchived ?? false, messageCount: 0, - syncVersion: 1 + syncVersion: 1, + systemPromptId: data.systemPromptId ?? null }; await db.conversations.add(stored); @@ -279,6 +282,16 @@ export async function updateMessageCount( }); } +/** + * Update the system prompt for a conversation + */ +export async function updateSystemPrompt( + conversationId: string, + systemPromptId: string | null +): Promise> { + return updateConversation(conversationId, { systemPromptId }); +} + /** * Search conversations by title */ diff --git a/frontend/src/lib/storage/db.ts b/frontend/src/lib/storage/db.ts index 7eda560..0d0b1bb 100644 --- a/frontend/src/lib/storage/db.ts +++ b/frontend/src/lib/storage/db.ts @@ -19,6 +19,8 @@ export interface StoredConversation { isArchived: boolean; messageCount: number; syncVersion?: number; + /** Optional system prompt ID for this conversation */ + systemPromptId?: string | null; } /** @@ -34,6 +36,8 @@ export interface ConversationRecord { isArchived: boolean; messageCount: number; syncVersion?: number; + /** Optional system prompt ID for this conversation */ + systemPromptId?: string | null; } /** @@ -186,6 +190,19 @@ class OllamaDatabase extends Dexie { // System prompt templates prompts: 'id, name, isDefault, updatedAt' }); + + // Version 4: Per-conversation system prompts + // Note: No schema change needed - just adding optional field to conversations + // Dexie handles this gracefully (field is undefined on old records) + this.version(4).stores({ + conversations: 'id, updatedAt, isPinned, isArchived, systemPromptId', + messages: 'id, conversationId, parentId, createdAt', + attachments: 'id, messageId', + syncQueue: 'id, entityType, createdAt', + documents: 'id, name, createdAt, updatedAt', + chunks: 'id, documentId', + prompts: 'id, name, isDefault, updatedAt' + }); } } diff --git a/frontend/src/lib/storage/index.ts b/frontend/src/lib/storage/index.ts index 38a66d3..407ceb4 100644 --- a/frontend/src/lib/storage/index.ts +++ b/frontend/src/lib/storage/index.ts @@ -26,6 +26,7 @@ export { pinConversation, archiveConversation, updateMessageCount, + updateSystemPrompt, searchConversations } from './conversations.js'; @@ -40,8 +41,10 @@ export { getMessageTree, getSiblings, getPathToMessage, - appendToMessage + appendToMessage, + searchMessages } from './messages.js'; +export type { MessageSearchResult } from './messages.js'; // Attachment operations export { diff --git a/frontend/src/lib/storage/messages.ts b/frontend/src/lib/storage/messages.ts index 51918a2..a33ace1 100644 --- a/frontend/src/lib/storage/messages.ts +++ b/frontend/src/lib/storage/messages.ts @@ -387,3 +387,68 @@ export async function appendToMessage( }); }); } + +/** Search result for message content search */ +export interface MessageSearchResult { + messageId: string; + conversationId: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + matchIndex: number; + createdAt: Date; +} + +/** + * Search messages by content + * Returns matching messages with context snippets + */ +export async function searchMessages( + query: string, + options?: { + conversationId?: string; + limit?: number; + } +): Promise> { + return withErrorHandling(async () => { + if (!query.trim()) { + return []; + } + + const lowerQuery = query.toLowerCase().trim(); + const limit = options?.limit ?? 50; + + // Get messages (optionally filtered by conversation) + let messagesQuery = db.messages.orderBy('createdAt').reverse(); + + const allMessages = await messagesQuery.toArray(); + + // Filter by content match and optional conversation filter + const matches: MessageSearchResult[] = []; + + for (const msg of allMessages) { + if (matches.length >= limit) break; + + // Skip if filtering by conversation and doesn't match + if (options?.conversationId && msg.conversationId !== options.conversationId) { + continue; + } + + // Check for content match + const lowerContent = msg.content.toLowerCase(); + const matchIndex = lowerContent.indexOf(lowerQuery); + + if (matchIndex !== -1) { + matches.push({ + messageId: msg.id, + conversationId: msg.conversationId, + role: msg.role, + content: msg.content, + matchIndex, + createdAt: new Date(msg.createdAt) + }); + } + } + + return matches; + }); +} diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index 7fab06d..4b24766 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -23,20 +23,20 @@ export class ChatState { streamingMessageId = $state(null); streamBuffer = $state(''); - // Derived: Get visible messages along the active path (excluding hidden messages for UI) + // Derived: Get visible messages along the active path (excluding hidden and summarized messages for UI) visibleMessages = $derived.by(() => { const messages: MessageNode[] = []; for (const id of this.activePath) { const node = this.messageTree.get(id); - // Skip hidden messages (e.g., internal tool result context) - if (node && !node.message.hidden) { + // Skip hidden messages and summarized messages (but show summary messages) + if (node && !node.message.hidden && !node.message.isSummarized) { messages.push(node); } } return messages; }); - // Derived: Get ALL messages along active path (including hidden, for API calls) + // Derived: Get ALL messages along active path (including hidden, for storage) allMessages = $derived.by(() => { const messages: MessageNode[] = []; for (const id of this.activePath) { @@ -48,6 +48,19 @@ export class ChatState { return messages; }); + // Derived: Get messages for API context (excludes summarized originals, includes summaries) + messagesForContext = $derived.by(() => { + const messages: MessageNode[] = []; + for (const id of this.activePath) { + const node = this.messageTree.get(id); + // Exclude summarized messages but include summary messages and hidden tool results + if (node && !node.message.isSummarized) { + messages.push(node); + } + } + return messages; + }); + // Derived: Can regenerate the last assistant message canRegenerate = $derived.by(() => { if (this.activePath.length === 0 || this.isStreaming) { @@ -364,6 +377,126 @@ export class ChatState { return Array.from(this.messageTree.values()).filter((node) => node.childIds.length === 0); } + /** + * Mark messages as summarized (they'll be hidden from UI and context) + * @param messageIds Array of message IDs to mark as summarized + */ + markAsSummarized(messageIds: string[]): void { + const newTree = new Map(this.messageTree); + + for (const id of messageIds) { + const node = newTree.get(id); + if (node) { + newTree.set(id, { + ...node, + message: { + ...node.message, + isSummarized: true + } + }); + } + } + + this.messageTree = newTree; + } + + /** + * Insert a summary message at the beginning of the conversation (after system message if present) + * @param summaryText The summary content + * @returns The ID of the inserted summary message + */ + insertSummaryMessage(summaryText: string): string { + const id = generateId(); + + // Find the first non-system message position in the active path + let insertIndex = 0; + for (let i = 0; i < this.activePath.length; i++) { + const node = this.messageTree.get(this.activePath[i]); + if (node?.message.role !== 'system') { + insertIndex = i; + break; + } + insertIndex = i + 1; + } + + // Determine parent: either the last system message or null + const parentId = insertIndex > 0 ? this.activePath[insertIndex - 1] : null; + + // Find the original first non-summarized message to connect to + let nextMessageId: string | null = null; + for (let i = insertIndex; i < this.activePath.length; i++) { + const node = this.messageTree.get(this.activePath[i]); + if (node && !node.message.isSummarized) { + nextMessageId = this.activePath[i]; + break; + } + } + + // Create the summary node + const summaryNode: MessageNode = { + id, + message: { + role: 'system', + content: `[Previous conversation summary]\n\n${summaryText}`, + isSummary: true + }, + parentId, + childIds: nextMessageId ? [nextMessageId] : [], + createdAt: new Date() + }; + + const newTree = new Map(this.messageTree); + + // Update parent to point to summary instead of original child + if (parentId) { + const parent = newTree.get(parentId); + if (parent && nextMessageId) { + newTree.set(parentId, { + ...parent, + childIds: parent.childIds.map((cid) => (cid === nextMessageId ? id : cid)) + }); + } else if (parent) { + newTree.set(parentId, { + ...parent, + childIds: [...parent.childIds, id] + }); + } + } + + // Update next message's parent to point to summary + if (nextMessageId) { + const nextNode = newTree.get(nextMessageId); + if (nextNode) { + newTree.set(nextMessageId, { + ...nextNode, + parentId: id + }); + } + } + + // Add summary node + newTree.set(id, summaryNode); + this.messageTree = newTree; + + // Update root if summary is at the beginning + if (!parentId) { + this.rootMessageId = id; + } + + // Rebuild active path to include summary + const newPath: string[] = []; + for (let i = 0; i < insertIndex; i++) { + newPath.push(this.activePath[i]); + } + newPath.push(id); + for (let i = insertIndex; i < this.activePath.length; i++) { + newPath.push(this.activePath[i]); + } + this.activePath = newPath; + + return id; + } + /** * Reset the chat state */ diff --git a/frontend/src/lib/stores/conversations.svelte.ts b/frontend/src/lib/stores/conversations.svelte.ts index 1df1c31..8fbf9c5 100644 --- a/frontend/src/lib/stores/conversations.svelte.ts +++ b/frontend/src/lib/stores/conversations.svelte.ts @@ -195,6 +195,15 @@ export class ConversationsState { clearSearch(): void { this.searchQuery = ''; } + + /** + * Update the system prompt for a conversation + * @param id The conversation ID + * @param systemPromptId The prompt ID (or null to clear) + */ + setSystemPrompt(id: string, systemPromptId: string | null): void { + this.update(id, { systemPromptId }); + } } /** Singleton conversations state instance */ diff --git a/frontend/src/lib/stores/models.svelte.ts b/frontend/src/lib/stores/models.svelte.ts index 5c7fc8a..47f9326 100644 --- a/frontend/src/lib/stores/models.svelte.ts +++ b/frontend/src/lib/stores/models.svelte.ts @@ -9,7 +9,7 @@ import { ollamaClient } from '$lib/ollama/client.js'; import { fetchRemoteModels, type RemoteModel } from '$lib/api/model-registry'; /** Known vision model families/patterns (fallback if API doesn't report) */ -const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision']; +const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision', 'ministral', 'pixtral', 'minicpm-v']; /** Capability display metadata */ export const CAPABILITY_INFO: Record = { @@ -128,8 +128,15 @@ export class ModelsState { }); // Derived: Check if selected model supports vision + // Uses capabilities cache first (from Ollama API), falls back to pattern matching selectedSupportsVision = $derived.by(() => { if (!this.selected) return false; + // Check capabilities cache first (most accurate) + const caps = this.capabilitiesCache.get(this.selected.name); + if (caps) { + return caps.includes('vision'); + } + // Fallback to pattern matching return isVisionModel(this.selected); }); diff --git a/frontend/src/lib/types/chat.ts b/frontend/src/lib/types/chat.ts index 46a8cfc..1f5fe60 100644 --- a/frontend/src/lib/types/chat.ts +++ b/frontend/src/lib/types/chat.ts @@ -24,6 +24,10 @@ export interface Message { toolCalls?: ToolCall[]; /** If true, message is hidden from UI (e.g., internal tool result messages) */ hidden?: boolean; + /** If true, this message has been summarized and should be excluded from context */ + isSummarized?: boolean; + /** If true, this is a summary message representing compressed conversation history */ + isSummary?: boolean; } /** A node in the message tree structure (for branching conversations) */ diff --git a/frontend/src/lib/types/conversation.ts b/frontend/src/lib/types/conversation.ts index 2747505..d6a1cb6 100644 --- a/frontend/src/lib/types/conversation.ts +++ b/frontend/src/lib/types/conversation.ts @@ -14,6 +14,8 @@ export interface Conversation { isPinned: boolean; isArchived: boolean; messageCount: number; + /** Optional system prompt ID for this conversation (null = use global default) */ + systemPromptId?: string | null; } /** Full conversation including message tree and navigation state */ diff --git a/frontend/src/lib/utils/keyboard.ts b/frontend/src/lib/utils/keyboard.ts index 2612486..0e74f1e 100644 --- a/frontend/src/lib/utils/keyboard.ts +++ b/frontend/src/lib/utils/keyboard.ts @@ -237,6 +237,18 @@ export function getShortcuts() { modifiers: getPrimaryModifiers(), description: 'Toggle sidebar' }, + FOCUS_INPUT: { + id: 'focus-input', + key: '/', + modifiers: getPrimaryModifiers(), + description: 'Focus chat input' + }, + SHOW_SHORTCUTS: { + id: 'show-shortcuts', + key: '?', + modifiers: { shift: true }, + description: 'Show keyboard shortcuts' + }, CLOSE_MODAL: { id: 'close-modal', key: 'Escape', diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1535c8e..06054a6 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -14,7 +14,7 @@ import Sidenav from '$lib/components/layout/Sidenav.svelte'; import TopNav from '$lib/components/layout/TopNav.svelte'; import ModelSelect from '$lib/components/layout/ModelSelect.svelte'; - import { ToastContainer } from '$lib/components/shared'; + import { ToastContainer, ShortcutsModal, SearchModal } from '$lib/components/shared'; import type { LayoutData } from './$types'; import type { Snippet } from 'svelte'; @@ -32,6 +32,9 @@ // Search modal state let showSearchModal = $state(false); + // Shortcuts modal state + let showShortcutsModal = $state(false); + onMount(() => { // Initialize UI state (handles responsive detection, theme, etc.) uiState.initialize(); @@ -82,20 +85,12 @@ } }); - // Search (Cmd/Ctrl + K) + // Search (Cmd/Ctrl + K) - opens global search modal keyboardShortcuts.register({ ...SHORTCUTS.SEARCH, preventDefault: true, handler: () => { - // Focus the search input in sidenav if open, otherwise open sidenav - if (!uiState.sidenavOpen) { - uiState.openSidenav(); - } - // Focus search after a short delay to allow sidenav to open - setTimeout(() => { - const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; - searchInput?.focus(); - }, 100); + showSearchModal = true; } }); @@ -107,6 +102,25 @@ uiState.toggleSidenav(); } }); + + // Focus chat input (Cmd/Alt + /) + keyboardShortcuts.register({ + ...SHORTCUTS.FOCUS_INPUT, + preventDefault: true, + handler: () => { + const chatInput = document.querySelector('[data-chat-input]') as HTMLTextAreaElement; + chatInput?.focus(); + } + }); + + // Show keyboard shortcuts (Shift + ?) + keyboardShortcuts.register({ + ...SHORTCUTS.SHOW_SHORTCUTS, + preventDefault: true, + handler: () => { + showShortcutsModal = !showShortcutsModal; + } + }); } /** @@ -157,3 +171,9 @@ + + + (showShortcutsModal = false)} /> + + + (showSearchModal = false)} />