diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6431c6..a29be72 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "dexie": "^4.0.10", "dompurify": "^3.2.0", "marked": "^15.0.0", + "pdfjs-dist": "^5.4.530", "shiki": "^1.26.0" }, "devDependencies": { @@ -505,6 +506,256 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", + "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.88", + "@napi-rs/canvas-darwin-arm64": "0.1.88", + "@napi-rs/canvas-darwin-x64": "0.1.88", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", + "@napi-rs/canvas-linux-arm64-musl": "0.1.88", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-musl": "0.1.88", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", + "@napi-rs/canvas-win32-x64-msvc": "0.1.88" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", + "integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", + "integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", + "integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", + "integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", + "integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", + "integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", + "integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", + "integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", + "integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", + "integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", + "integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2270,6 +2521,18 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.530", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", + "integrity": "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.84" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index efde376..e9d6a60 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "dexie": "^4.0.10", "dompurify": "^3.2.0", "marked": "^15.0.0", + "pdfjs-dist": "^5.4.530", "shiki": "^1.26.0" } } diff --git a/frontend/src/lib/components/chat/ChatInput.svelte b/frontend/src/lib/components/chat/ChatInput.svelte index f9ecf5c..2917150 100644 --- a/frontend/src/lib/components/chat/ChatInput.svelte +++ b/frontend/src/lib/components/chat/ChatInput.svelte @@ -1,11 +1,13 @@
- - {#if isVisionModel} - - {/if} + +
- - {#if isVisionModel && pendingImages.length > 0} -
- - - - - {pendingImages.length} - + + {#if pendingImages.length > 0 || pendingAttachments.length > 0} +
+ {#if pendingImages.length > 0} + + + + + {pendingImages.length} + + {/if} + {#if pendingAttachments.length > 0} + + + + + {pendingAttachments.length} + + {/if}
{/if} @@ -220,9 +263,11 @@ Enter send ยท Shift+Enter new line + ยท {#if isVisionModel} - ยท - images supported + images + + {/if} + files supported

diff --git a/frontend/src/lib/components/chat/ChatWindow.svelte b/frontend/src/lib/components/chat/ChatWindow.svelte index bc7edfb..b7d08c5 100644 --- a/frontend/src/lib/components/chat/ChatWindow.svelte +++ b/frontend/src/lib/components/chat/ChatWindow.svelte @@ -36,9 +36,16 @@ mode?: 'new' | 'conversation'; onFirstMessage?: (content: string, images?: string[]) => Promise; conversation?: Conversation | null; + /** Bindable prop for thinking mode - synced with parent in 'new' mode */ + thinkingEnabled?: boolean; } - let { mode = 'new', onFirstMessage, conversation }: Props = $props(); + let { + mode = 'new', + onFirstMessage, + conversation, + thinkingEnabled = $bindable(true) + }: Props = $props(); // Local state for abort controller let abortController: AbortController | null = $state(null); @@ -54,6 +61,12 @@ let hasKnowledgeBase = $state(false); let lastRagContext = $state(null); + // Derived: Check if selected model supports thinking + const supportsThinking = $derived.by(() => { + const caps = modelsState.selectedCapabilities; + return caps.includes('thinking'); + }); + // Check for knowledge base on mount $effect(() => { checkKnowledgeBase(); @@ -273,7 +286,7 @@ let messages = getMessagesForApi(); const tools = getToolsForApi(); - // Build system prompt from active prompt + RAG context + // Build system prompt from active prompt + thinking + RAG context const systemParts: string[] = []; // Wait for prompts to be loaded, then add system prompt if active @@ -284,6 +297,15 @@ console.log('[Chat] Using system prompt:', activePrompt.name); } + // Log thinking mode status (now using native API support, not prompt-based) + console.log('[Chat] Thinking mode check:', { + supportsThinking, + thinkingEnabled, + selectedModel: modelsState.selectedId, + selectedCapabilities: modelsState.selectedCapabilities + }); + // Note: Thinking is now handled via the `think: true` API parameter instead of prompt injection + // RAG: Retrieve relevant context for the last user message const lastUserMessage = messages.filter(m => m.role === 'user').pop(); if (lastUserMessage && ragEnabled && hasKnowledgeBase) { @@ -316,14 +338,37 @@ console.log('[Chat] USE_FUNCTION_MODEL:', USE_FUNCTION_MODEL); console.log('[Chat] Using model:', chatModel, '(original:', model, ')'); + // Determine if we should use native thinking mode + const useNativeThinking = supportsThinking && thinkingEnabled; + console.log('[Chat] Native thinking mode:', useNativeThinking); + + // Track thinking content during streaming + let streamingThinking = ''; + let thinkingClosed = false; + await ollamaClient.streamChatWithCallbacks( { model: chatModel, messages, - tools + tools, + think: useNativeThinking }, { + onThinkingToken: (token) => { + // Accumulate thinking and update the message + if (!streamingThinking) { + // Start the thinking block + chatState.appendToStreaming(''); + } + streamingThinking += token; + chatState.appendToStreaming(token); + }, onToken: (token) => { + // Close thinking block when content starts + if (streamingThinking && !thinkingClosed) { + chatState.appendToStreaming('\n\n'); + thinkingClosed = true; + } chatState.appendToStreaming(token); }, onToolCall: (toolCalls) => { @@ -332,6 +377,12 @@ console.log('Tool calls received:', toolCalls); }, onComplete: async () => { + // Close thinking block if it was opened but not closed (e.g., tool calls without content) + if (streamingThinking && !thinkingClosed) { + chatState.appendToStreaming('\n\n'); + thinkingClosed = true; + } + chatState.finishStreaming(); abortController = null; @@ -405,7 +456,17 @@ // Update the assistant message with tool call info and structured data const assistantNode = chatState.messageTree.get(assistantMessageId); if (assistantNode) { - assistantNode.message.content = toolCallInfo + '\n\n' + toolResultContent; + // Preserve any thinking content that was already streamed + const existingContent = assistantNode.message.content || ''; + const newContent = toolCallInfo + '\n\n' + toolResultContent; + + // If there's existing content (like thinking), append tool info after it + if (existingContent.trim()) { + assistantNode.message.content = existingContent + '\n\n' + newContent; + } else { + assistantNode.message.content = newContent; + } + // Store structured tool call data for display assistantNode.message.toolCalls = toolCalls.map(tc => ({ id: crypto.randomUUID(), @@ -414,18 +475,15 @@ })); } - // Persist the assistant message with tool info - if (conversationId) { - const parentNode = chatState.messageTree.get(assistantMessageId); - if (parentNode) { - const parentOfAssistant = parentNode.parentId; - await addStoredMessage( - conversationId, - { role: 'assistant', content: toolCallInfo + '\n\n' + toolResultContent }, - parentOfAssistant, - assistantMessageId - ); - } + // Persist the assistant message with tool info (including any thinking content) + if (conversationId && assistantNode) { + const parentOfAssistant = assistantNode.parentId; + await addStoredMessage( + conversationId, + { role: 'assistant', content: assistantNode.message.content }, + parentOfAssistant, + assistantMessageId + ); } // Now stream a follow-up response that uses the tool results @@ -621,6 +679,7 @@
{:else} @@ -645,6 +704,29 @@
{/if} + + {#if supportsThinking} +
+ +
+ {/if} +
+ /** + * FilePreview.svelte - Preview for attached text/PDF files + * Shows filename, size, and expandable content preview + * Includes remove button on hover + */ + import type { FileAttachment } from '$lib/types/attachment.js'; + import { formatFileSize, getFileIcon } from '$lib/utils/file-processor.js'; + + interface Props { + attachment: FileAttachment; + onRemove?: (id: string) => void; + readonly?: boolean; + } + + const { attachment, onRemove, readonly = false }: 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 ? '...' : '') + : '' + ); + + function handleRemove() { + onRemove?.(attachment.id); + } + + function toggleExpand() { + if (hasContent) { + isExpanded = !isExpanded; + } + } + + +
+ +
+ {getFileIcon(attachment.type)} +
+ + +
+
+
+

+ {attachment.filename} +

+

+ {formatFileSize(attachment.size)} + {#if attachment.type === 'pdf'} + ยท + PDF + {/if} +

+
+ + + {#if !readonly && onRemove} + + {/if} +
+ + + {#if hasContent} + + {/if} +
+
diff --git a/frontend/src/lib/components/chat/FileUpload.svelte b/frontend/src/lib/components/chat/FileUpload.svelte new file mode 100644 index 0000000..e416e37 --- /dev/null +++ b/frontend/src/lib/components/chat/FileUpload.svelte @@ -0,0 +1,243 @@ + + +
+ + {#if supportsVision} + + {/if} + + + {#if hasAttachments} +
+ {#each attachments as attachment (attachment.id)} + + {/each} +
+ {/if} + + +
+ + + + + + + + + {#if supportsVision} + Images, text files, PDFs + {:else} + Text files, PDFs (content will be included in message) + {/if} + +
+ + + {#if errorMessage} +
+ {errorMessage} +
+ {/if} +
diff --git a/frontend/src/lib/components/chat/MessageContent.svelte b/frontend/src/lib/components/chat/MessageContent.svelte index 6d02cf1..16ea667 100644 --- a/frontend/src/lib/components/chat/MessageContent.svelte +++ b/frontend/src/lib/components/chat/MessageContent.svelte @@ -9,19 +9,26 @@ import CodeBlock from './CodeBlock.svelte'; import HtmlPreview from './HtmlPreview.svelte'; import ToolResultDisplay from './ToolResultDisplay.svelte'; + import ThinkingBlock from './ThinkingBlock.svelte'; import { base64ToDataUrl } from '$lib/ollama/image-processor'; interface Props { content: string; images?: string[]; isStreaming?: boolean; + /** Whether to show thinking blocks (hide when thinking mode is disabled) */ + showThinking?: boolean; } - const { content, images, isStreaming = false }: Props = $props(); + const { content, images, isStreaming = false, showThinking = true }: Props = $props(); // Pattern to find fenced code blocks const CODE_BLOCK_PATTERN = /```(\w+)?\n([\s\S]*?)```/g; + // Pattern to find thinking blocks (used by reasoning models) + // Supports both ... and ... (qwen3 format) + const THINKING_PATTERN = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/g; + // Pattern to detect tool results in various formats const TOOL_RESULT_PATTERN = /Tool result:\s*(\{[\s\S]*?\}|\S[\s\S]*?)(?=\n\n|$)/; const TOOL_ERROR_PATTERN = /Tool error:\s*(.+?)(?=\n\n|$)/; @@ -36,7 +43,7 @@ const PREVIEW_LANGUAGES = ['html', 'htm']; interface ContentPart { - type: 'text' | 'code' | 'tool-result'; + type: 'text' | 'code' | 'tool-result' | 'thinking'; content: string; language?: string; showPreview?: boolean; @@ -100,10 +107,58 @@ } /** - * Parse content into parts (text, code blocks, and tool results) + * Extract thinking blocks and return remaining text + * Handles both complete and unclosed (streaming) thinking blocks */ - function parseContent(text: string): ContentPart[] { + function extractThinkingBlocks(text: string): { thinkingParts: ContentPart[]; remainingText: string; isThinkingInProgress: boolean } { + const thinkingParts: ContentPart[] = []; + THINKING_PATTERN.lastIndex = 0; + + let remainingText = text; + let match; + let isThinkingInProgress = false; + + // Find all complete thinking blocks + while ((match = THINKING_PATTERN.exec(text)) !== null) { + thinkingParts.push({ + type: 'thinking', + content: match[1].trim() + }); + } + + // Remove complete thinking blocks from text + remainingText = text.replace(THINKING_PATTERN, '').trim(); + + // Check for unclosed thinking block during streaming + // Pattern: starts with but no closing tag + const unclosedPattern = /^<(?:thinking|think)>([\s\S]*)$/; + const unclosedMatch = remainingText.match(unclosedPattern); + if (unclosedMatch && isStreaming) { + // This is an in-progress thinking block + thinkingParts.push({ + type: 'thinking', + content: unclosedMatch[1].trim() + }); + remainingText = ''; + isThinkingInProgress = true; + } + + return { thinkingParts, remainingText, isThinkingInProgress }; + } + + /** + * Parse content into parts (text, code blocks, thinking, and tool results) + */ + function parseContent(text: string): { parts: ContentPart[]; isThinkingInProgress: boolean } { const parts: ContentPart[] = []; + + // First, extract thinking blocks (they should appear at the top) + const { thinkingParts, remainingText, isThinkingInProgress } = extractThinkingBlocks(text); + + // Add thinking parts first + parts.push(...thinkingParts); + + // Now parse the remaining text for code blocks and tool results let lastIndex = 0; // Reset regex state @@ -111,10 +166,10 @@ // Find all code blocks let match; - while ((match = CODE_BLOCK_PATTERN.exec(text)) !== null) { + while ((match = CODE_BLOCK_PATTERN.exec(remainingText)) !== null) { // Add text before this code block (may contain tool results) if (match.index > lastIndex) { - const textBefore = text.slice(lastIndex, match.index); + const textBefore = remainingText.slice(lastIndex, match.index); if (textBefore.trim()) { if (containsToolResult(textBefore)) { parts.push(...parseTextForToolResults(textBefore)); @@ -137,8 +192,8 @@ } // Add remaining text after last code block - if (lastIndex < text.length) { - const remaining = text.slice(lastIndex); + if (lastIndex < remainingText.length) { + const remaining = remainingText.slice(lastIndex); if (remaining.trim()) { if (containsToolResult(remaining)) { parts.push(...parseTextForToolResults(remaining)); @@ -148,16 +203,16 @@ } } - // If no code blocks found, check for tool results in entire content - if (parts.length === 0 && text.trim()) { - if (containsToolResult(text)) { - parts.push(...parseTextForToolResults(text)); + // If no code blocks found and no thinking, check for tool results in entire content + if (parts.length === thinkingParts.length && remainingText.trim()) { + if (containsToolResult(remainingText)) { + parts.push(...parseTextForToolResults(remainingText)); } else { - parts.push({ type: 'text', content: text }); + parts.push({ type: 'text', content: remainingText }); } } - return parts; + return { parts, isThinkingInProgress }; } /** @@ -219,7 +274,22 @@ // Clean and parse content into parts const cleanedContent = $derived(cleanToolText(content)); - const contentParts = $derived(parseContent(cleanedContent)); + const parsedContent = $derived.by(() => { + const result = parseContent(cleanedContent); + // Debug: Log if thinking blocks were found + const thinkingParts = result.parts.filter(p => p.type === 'thinking'); + if (thinkingParts.length > 0) { + console.log('[MessageContent] Found thinking blocks:', thinkingParts.length, 'in-progress:', result.isThinkingInProgress); + } + return result; + }); + // Filter out thinking parts if showThinking is false + const contentParts = $derived( + showThinking + ? parsedContent.parts + : parsedContent.parts.filter(p => p.type !== 'thinking') + ); + const isThinkingInProgress = $derived(showThinking && parsedContent.isThinkingInProgress);
@@ -255,7 +325,12 @@ {#each contentParts as part, index (index)} - {#if part.type === 'code'} + {#if part.type === 'thinking'} + + {:else if part.type === 'code'}
void; onRegenerate?: () => void; onEdit?: (newContent: string) => void; @@ -26,6 +28,7 @@ branchInfo, isStreaming = false, isLast = false, + showThinking = true, onBranchSwitch, onRegenerate, onEdit @@ -189,6 +192,7 @@ content={node.message.content} images={node.message.images} {isStreaming} + {showThinking} /> {/if} diff --git a/frontend/src/lib/components/chat/MessageList.svelte b/frontend/src/lib/components/chat/MessageList.svelte index 03f3244..7242726 100644 --- a/frontend/src/lib/components/chat/MessageList.svelte +++ b/frontend/src/lib/components/chat/MessageList.svelte @@ -11,9 +11,11 @@ interface Props { onRegenerate?: () => void; onEditMessage?: (messageId: string, newContent: string) => void; + /** Whether to show thinking blocks in messages */ + showThinking?: boolean; } - const { onRegenerate, onEditMessage }: Props = $props(); + const { onRegenerate, onEditMessage, showThinking = true }: Props = $props(); // Reference to scroll container and anchor element let scrollContainer: HTMLDivElement | null = $state(null); @@ -162,6 +164,7 @@ branchInfo={getBranchInfo(node)} isStreaming={isStreamingMessage(node)} isLast={isLastMessage(index)} + {showThinking} onBranchSwitch={(direction) => handleBranchSwitch(node.id, direction)} onRegenerate={onRegenerate} onEdit={(newContent) => onEditMessage?.(node.id, newContent)} diff --git a/frontend/src/lib/components/chat/ThinkingBlock.svelte b/frontend/src/lib/components/chat/ThinkingBlock.svelte new file mode 100644 index 0000000..e841c25 --- /dev/null +++ b/frontend/src/lib/components/chat/ThinkingBlock.svelte @@ -0,0 +1,152 @@ + + +
+ + + + + {#if isExpanded} +
+
+ {@html renderMarkdown(content)} + {#if inProgress} + + {/if} +
+
+ {/if} +
+ + diff --git a/frontend/src/lib/components/layout/ModelCapabilityIcons.svelte b/frontend/src/lib/components/layout/ModelCapabilityIcons.svelte new file mode 100644 index 0000000..e0ef45b --- /dev/null +++ b/frontend/src/lib/components/layout/ModelCapabilityIcons.svelte @@ -0,0 +1,91 @@ + + +{#if displayCapabilities.length > 0} +
+ {#each displayCapabilities as cap (cap)} + {@const info = getCapabilityInfo(cap)} + {@const colors = getColorClasses(info.color)} + + {info.icon} + + {/each} +
+{/if} diff --git a/frontend/src/lib/components/layout/ModelSelect.svelte b/frontend/src/lib/components/layout/ModelSelect.svelte index 589ca9d..ca89d30 100644 --- a/frontend/src/lib/components/layout/ModelSelect.svelte +++ b/frontend/src/lib/components/layout/ModelSelect.svelte @@ -2,9 +2,10 @@ /** * ModelSelect.svelte - Dropdown for selecting Ollama models * Uses modelsState from $lib/stores - * Shows vision capability indicator for compatible models + * Shows capability icons for models (vision, tools, code, etc.) */ import { modelsState } from '$lib/stores'; + import ModelCapabilityIcons from './ModelCapabilityIcons.svelte'; /** Track dropdown open state */ let isOpen = $state(false); @@ -22,13 +23,6 @@ return `${mb.toFixed(0)} MB`; } - /** Check if a model is vision-capable */ - function isVisionCapable(modelName: string): boolean { - const model = modelsState.getByName(modelName); - if (!model) return false; - return modelsState.visionModels.some(v => v.name === modelName); - } - /** Handle model selection */ function selectModel(modelName: string) { modelsState.select(modelName); @@ -87,21 +81,8 @@
{modelsState.selected.name} - - {#if modelsState.selectedSupportsVision} - - - - - - Vision - - {/if} + +
{modelsState.selected.details.parameter_size}
@@ -166,20 +147,8 @@ {model.name} - - {#if isVisionCapable(model.name)} - - - - - - - {/if} + +
{model.details.parameter_size} diff --git a/frontend/src/lib/ollama/client.ts b/frontend/src/lib/ollama/client.ts index cd1bbd5..42bb8e9 100644 --- a/frontend/src/lib/ollama/client.ts +++ b/frontend/src/lib/ollama/client.ts @@ -415,6 +415,10 @@ export class OllamaClient { request.keep_alive = options.keepAlive; } + if (options.think !== undefined) { + request.think = options.think; + } + return request; } @@ -461,6 +465,8 @@ export interface ChatOptions { keepAlive?: string; /** Request timeout in milliseconds */ timeoutMs?: number; + /** Enable thinking mode for reasoning models (qwen3, deepseek-r1, etc.) */ + think?: boolean; } /** Result of connection test */ diff --git a/frontend/src/lib/ollama/streaming.ts b/frontend/src/lib/ollama/streaming.ts index 58270ba..85bdcc8 100644 --- a/frontend/src/lib/ollama/streaming.ts +++ b/frontend/src/lib/ollama/streaming.ts @@ -37,6 +37,8 @@ export interface StreamChatOptions { export interface StreamChatResult { /** Full accumulated response text */ content: string; + /** Accumulated thinking/reasoning content (for reasoning models) */ + thinking?: string; /** Final response with metrics (if stream completed) */ response?: OllamaChatResponse; /** Tool calls made by the model (if any) */ @@ -205,6 +207,7 @@ export async function* streamChat( const parser = new NDJSONParser(); let accumulatedContent = ''; + let accumulatedThinking = ''; let finalResponse: OllamaChatResponse | undefined; let toolCalls: OllamaToolCall[] | undefined; @@ -223,6 +226,9 @@ export async function* streamChat( if (chunk.message?.content) { accumulatedContent += chunk.message.content; } + if (chunk.message?.thinking) { + accumulatedThinking += chunk.message.thinking; + } if (chunk.message?.tool_calls) { toolCalls = chunk.message.tool_calls; } @@ -239,6 +245,9 @@ export async function* streamChat( if (chunk.message?.content) { accumulatedContent += chunk.message.content; } + if (chunk.message?.thinking) { + accumulatedThinking += chunk.message.thinking; + } if (chunk.message?.tool_calls) { toolCalls = chunk.message.tool_calls; } @@ -260,6 +269,7 @@ export async function* streamChat( return { content: accumulatedContent, + thinking: accumulatedThinking || undefined, response: finalResponse, toolCalls }; @@ -273,6 +283,8 @@ export async function* streamChat( export interface StreamChatCallbacks { /** Called for each content token */ onToken?: (token: string) => void; + /** Called for each thinking token (reasoning models with think: true) */ + onThinkingToken?: (token: string) => void; /** Called with full chunk data */ onChunk?: (chunk: OllamaChatStreamChunk) => void; /** Called when tool calls are received from the model */ @@ -295,7 +307,7 @@ export async function streamChatWithCallbacks( callbacks: StreamChatCallbacks, options: StreamChatOptions ): Promise { - const { onToken, onChunk, onToolCall, onComplete, onError } = callbacks; + const { onToken, onThinkingToken, onChunk, onToolCall, onComplete, onError } = callbacks; try { const stream = streamChat(request, options); @@ -313,6 +325,11 @@ export async function streamChatWithCallbacks( // Call chunk callback onChunk?.(value); + // Call thinking token callback for reasoning content + if (value.message?.thinking) { + onThinkingToken?.(value.message.thinking); + } + // Call token callback for content if (value.message?.content) { onToken?.(value.message.content); diff --git a/frontend/src/lib/ollama/types.ts b/frontend/src/lib/ollama/types.ts index ace7fcf..a3f4f5e 100644 --- a/frontend/src/lib/ollama/types.ts +++ b/frontend/src/lib/ollama/types.ts @@ -74,6 +74,8 @@ export interface OllamaMessage { images?: string[]; /** Tool calls made by the assistant */ tool_calls?: OllamaToolCall[]; + /** Thinking/reasoning content from reasoning models (when think: true) */ + thinking?: string; } // ============================================================================ @@ -169,6 +171,8 @@ export interface OllamaChatRequest { options?: OllamaModelOptions; /** How long to keep model loaded (e.g., "5m", "1h", "-1" for indefinite) */ keep_alive?: string; + /** Enable thinking mode for reasoning models (qwen3, deepseek-r1, etc.) */ + think?: boolean; } /** Performance metrics in chat response */ @@ -317,6 +321,18 @@ export interface OllamaShowRequest { verbose?: boolean; } +/** Model capability types reported by Ollama */ +export type OllamaCapability = + | 'completion' // Text generation + | 'vision' // Image analysis + | 'tools' // Function calling + | 'embedding' // Vector embeddings + | 'thinking' // Reasoning/CoT + | 'code' // Coding optimized + | 'uncensored' // No guardrails + | 'cloud' // Cloud offloading + | string; // Allow other capabilities + /** Response from POST /api/show */ export interface OllamaShowResponse { license?: string; @@ -326,6 +342,8 @@ export interface OllamaShowResponse { details: OllamaModelDetails; model_info?: Record; modified_at: string; + /** Model capabilities (vision, tools, code, etc.) */ + capabilities?: OllamaCapability[]; } // ============================================================================ diff --git a/frontend/src/lib/stores/index.ts b/frontend/src/lib/stores/index.ts index 685d06a..e7d5ed2 100644 --- a/frontend/src/lib/stores/index.ts +++ b/frontend/src/lib/stores/index.ts @@ -4,7 +4,7 @@ export { ChatState, chatState } from './chat.svelte.js'; export { ConversationsState, conversationsState } from './conversations.svelte.js'; -export { ModelsState, modelsState } from './models.svelte.js'; +export { ModelsState, modelsState, CAPABILITY_INFO } from './models.svelte.js'; export { UIState, uiState } from './ui.svelte.js'; export { ToastState, toastState } from './toast.svelte.js'; export { toolsState } from './tools.svelte.js'; diff --git a/frontend/src/lib/stores/models.svelte.ts b/frontend/src/lib/stores/models.svelte.ts index 3db3a73..209b846 100644 --- a/frontend/src/lib/stores/models.svelte.ts +++ b/frontend/src/lib/stores/models.svelte.ts @@ -4,10 +4,23 @@ */ import type { OllamaModel, ModelGroup } from '$lib/types/model.js'; +import type { OllamaCapability } from '$lib/ollama/types.js'; +import { ollamaClient } from '$lib/ollama/client.js'; -/** Known vision model families/patterns */ +/** Known vision model families/patterns (fallback if API doesn't report) */ const VISION_PATTERNS = ['llava', 'bakllava', 'moondream', 'vision']; +/** Capability display metadata */ +export const CAPABILITY_INFO: Record = { + vision: { label: 'Vision', icon: '๐Ÿ‘', color: 'purple' }, + tools: { label: 'Tools', icon: '๐Ÿ”ง', color: 'blue' }, + code: { label: 'Code', icon: '๐Ÿ’ป', color: 'emerald' }, + thinking: { label: 'Reasoning', icon: '๐Ÿง ', color: 'amber' }, + uncensored: { label: 'Uncensored', icon: '๐Ÿ”“', color: 'red' }, + cloud: { label: 'Cloud', icon: 'โ˜๏ธ', color: 'sky' }, + embedding: { label: 'Embedding', icon: '๐Ÿ“Š', color: 'slate' } +}; + /** * Middleware models that should NOT appear in the chat model selector * These are special-purpose models for embeddings, function routing, etc. @@ -52,6 +65,10 @@ export class ModelsState { isLoading = $state(false); error = $state(null); + // Capabilities cache: modelName -> capabilities array + private capabilitiesCache = $state>(new Map()); + private capabilitiesFetching = new Set(); + // Derived: Currently selected model selected = $derived.by(() => { if (!this.selectedId) return null; @@ -206,6 +223,95 @@ export class ModelsState { hasModel(modelName: string): boolean { return this.available.some((m) => m.name === modelName); } + + // ========================================================================= + // Capabilities + // ========================================================================= + + /** + * Get cached capabilities for a model + * Returns undefined if not yet fetched + */ + getCapabilities(modelName: string): OllamaCapability[] | undefined { + return this.capabilitiesCache.get(modelName); + } + + /** + * Check if a model has a specific capability + */ + hasCapability(modelName: string, capability: OllamaCapability): boolean { + const caps = this.capabilitiesCache.get(modelName); + if (caps) { + return caps.includes(capability); + } + // Fallback to pattern matching for vision if not fetched + if (capability === 'vision') { + const model = this.getByName(modelName); + return model ? isVisionModel(model) : false; + } + return false; + } + + /** + * Fetch capabilities for a model from the API + * Uses caching to avoid repeated requests + */ + async fetchCapabilities(modelName: string): Promise { + // Return cached if available + const cached = this.capabilitiesCache.get(modelName); + if (cached) return cached; + + // Avoid duplicate fetches + if (this.capabilitiesFetching.has(modelName)) { + // Wait a bit and check cache again + await new Promise((r) => setTimeout(r, 100)); + return this.capabilitiesCache.get(modelName) ?? []; + } + + this.capabilitiesFetching.add(modelName); + + try { + const response = await ollamaClient.showModel(modelName); + const capabilities = response.capabilities ?? []; + + // Update cache reactively + const newCache = new Map(this.capabilitiesCache); + newCache.set(modelName, capabilities); + this.capabilitiesCache = newCache; + + return capabilities; + } catch (err) { + console.warn(`Failed to fetch capabilities for ${modelName}:`, err); + // Fallback to pattern matching for vision + const model = this.getByName(modelName); + if (model && isVisionModel(model)) { + const fallback: OllamaCapability[] = ['vision']; + const newCache = new Map(this.capabilitiesCache); + newCache.set(modelName, fallback); + this.capabilitiesCache = newCache; + return fallback; + } + return []; + } finally { + this.capabilitiesFetching.delete(modelName); + } + } + + /** + * Fetch capabilities for all available models + */ + async fetchAllCapabilities(): Promise { + const promises = this.chatModels.map((m) => this.fetchCapabilities(m.name)); + await Promise.allSettled(promises); + } + + /** + * Get capabilities for selected model (cached) + */ + get selectedCapabilities(): OllamaCapability[] { + if (!this.selectedId) return []; + return this.capabilitiesCache.get(this.selectedId) ?? []; + } } /** Singleton models state instance */ diff --git a/frontend/src/lib/types/attachment.ts b/frontend/src/lib/types/attachment.ts new file mode 100644 index 0000000..31d860b --- /dev/null +++ b/frontend/src/lib/types/attachment.ts @@ -0,0 +1,166 @@ +/** + * File attachment types for multi-modal chat + * Supports images (for vision models), text files, and PDFs (content extracted as text) + */ + +// ============================================================================ +// Core Types +// ============================================================================ + +/** Type of file attachment */ +export type AttachmentType = 'image' | 'text' | 'pdf'; + +/** File attachment with extracted content */ +export interface FileAttachment { + /** Unique identifier */ + id: string; + /** Type of attachment */ + type: AttachmentType; + /** Original filename */ + filename: string; + /** MIME type (e.g., 'image/png', 'text/plain', 'application/pdf') */ + mimeType: string; + /** File size in bytes */ + size: number; + /** Extracted text content (for text/pdf types) */ + textContent?: string; + /** Base64-encoded data (for images, WITHOUT data: prefix for Ollama) */ + base64Data?: string; + /** Preview thumbnail for images (data URI with prefix for display) */ + previewUrl?: string; +} + +// ============================================================================ +// File Type Detection +// ============================================================================ + +/** Common text file extensions */ +export const TEXT_FILE_EXTENSIONS = [ + '.txt', + '.md', + '.markdown', + '.json', + '.js', + '.jsx', + '.ts', + '.tsx', + '.py', + '.go', + '.rs', + '.java', + '.c', + '.cpp', + '.h', + '.hpp', + '.rb', + '.php', + '.sh', + '.bash', + '.zsh', + '.sql', + '.css', + '.scss', + '.sass', + '.less', + '.html', + '.htm', + '.xml', + '.yaml', + '.yml', + '.toml', + '.ini', + '.cfg', + '.conf', + '.env', + '.gitignore', + '.dockerignore', + '.svelte', + '.vue', + '.astro' +] as const; + +/** Image MIME types we support */ +export const IMAGE_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp' +] as const; + +/** Text MIME types we support */ +export const TEXT_MIME_TYPES = [ + 'text/plain', + 'text/markdown', + 'text/html', + 'text/css', + 'text/javascript', + 'text/xml', + 'application/json', + 'application/javascript', + 'application/xml', + 'application/x-yaml' +] as const; + +/** PDF MIME type */ +export const PDF_MIME_TYPE = 'application/pdf'; + +// ============================================================================ +// Size Limits +// ============================================================================ + +/** Maximum image size (2MB after compression) */ +export const MAX_IMAGE_SIZE = 2 * 1024 * 1024; + +/** Maximum text file size (1MB) */ +export const MAX_TEXT_SIZE = 1 * 1024 * 1024; + +/** Maximum PDF size (10MB) */ +export const MAX_PDF_SIZE = 10 * 1024 * 1024; + +/** Maximum image dimensions (LLaVA limit) */ +export const MAX_IMAGE_DIMENSION = 1344; + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** Check if MIME type is an image */ +export function isImageMimeType(mimeType: string): boolean { + return IMAGE_MIME_TYPES.includes(mimeType as typeof IMAGE_MIME_TYPES[number]); +} + +/** Check if MIME type is text */ +export function isTextMimeType(mimeType: string): boolean { + return TEXT_MIME_TYPES.includes(mimeType as typeof TEXT_MIME_TYPES[number]); +} + +/** Check if MIME type is PDF */ +export function isPdfMimeType(mimeType: string): boolean { + return mimeType === PDF_MIME_TYPE; +} + +/** Check if file extension is a known text type */ +export function isTextExtension(filename: string): boolean { + const lower = filename.toLowerCase(); + return TEXT_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +// ============================================================================ +// Utility Types +// ============================================================================ + +/** Result of processing a file */ +export interface ProcessFileResult { + success: true; + attachment: FileAttachment; +} + +/** Error during file processing */ +export interface ProcessFileError { + success: false; + error: string; +} + +/** Combined result type */ +export type ProcessFileOutcome = ProcessFileResult | ProcessFileError; diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts index 6bb04dc..2e5ebbb 100644 --- a/frontend/src/lib/types/index.ts +++ b/frontend/src/lib/types/index.ts @@ -2,6 +2,7 @@ * Type exports */ +export * from './attachment.js'; export * from './chat.js'; export * from './conversation.js'; export * from './model.js'; diff --git a/frontend/src/lib/utils/file-processor.ts b/frontend/src/lib/utils/file-processor.ts new file mode 100644 index 0000000..4e3078b --- /dev/null +++ b/frontend/src/lib/utils/file-processor.ts @@ -0,0 +1,312 @@ +/** + * File processor utility + * Handles reading, processing, and extracting content from files + * Supports images, text files, and PDFs + */ + +import type { + AttachmentType, + FileAttachment, + ProcessFileOutcome +} from '$lib/types/attachment.js'; +import { + isImageMimeType, + isTextMimeType, + isPdfMimeType, + isTextExtension, + MAX_IMAGE_SIZE, + MAX_TEXT_SIZE, + MAX_PDF_SIZE, + MAX_IMAGE_DIMENSION +} from '$lib/types/attachment.js'; + +// ============================================================================ +// File Type Detection +// ============================================================================ + +/** + * Detect the attachment type for a file + * @returns The attachment type or null if unsupported + */ +export function detectFileType(file: File): AttachmentType | null { + const mimeType = file.type.toLowerCase(); + + if (isImageMimeType(mimeType)) { + return 'image'; + } + + if (isPdfMimeType(mimeType)) { + return 'pdf'; + } + + if (isTextMimeType(mimeType)) { + return 'text'; + } + + // Check by extension as fallback + if (isTextExtension(file.name)) { + return 'text'; + } + + return null; +} + +// ============================================================================ +// Text File Processing +// ============================================================================ + +/** + * Read a text file and return its content + */ +export async function readTextFile(file: File): Promise { + 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.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); +} + +// ============================================================================ +// Image Processing +// ============================================================================ + +/** + * Process an image file: resize if needed, compress, and return base64 + */ +export async function processImage(file: File): Promise<{ base64: string; previewUrl: string }> { + if (file.size > MAX_IMAGE_SIZE * 5) { + // Allow larger initial size, we'll compress + throw new Error(`Image too large. Maximum size is ${(MAX_IMAGE_SIZE * 5) / 1024 / 1024}MB`); + } + + return new Promise((resolve, reject) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(objectUrl); + + // Calculate new dimensions + let { width, height } = img; + if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + const ratio = Math.min(MAX_IMAGE_DIMENSION / width, MAX_IMAGE_DIMENSION / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + + // Draw to canvas and compress + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Failed to create canvas context')); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + + // Get as JPEG for compression (better than PNG for most cases) + const quality = 0.85; + const dataUrl = canvas.toDataURL('image/jpeg', quality); + + // Extract base64 without the data: prefix (Ollama requirement) + const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + + resolve({ + base64, + previewUrl: dataUrl + }); + }; + + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Failed to load image')); + }; + + img.src = objectUrl; + }); +} + +// ============================================================================ +// PDF Processing +// ============================================================================ + +// PDF.js will be loaded dynamically when needed +let pdfjsLib: typeof import('pdfjs-dist') | null = null; + +/** + * Load PDF.js library dynamically + */ +async function loadPdfJs(): Promise { + if (pdfjsLib) return pdfjsLib; + + 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`; + + return pdfjsLib; + } catch (error) { + throw new Error('PDF.js library not available. Install with: npm install pdfjs-dist'); + } +} + +/** + * Extract text content from a PDF file + */ +export async function extractPdfText(file: File): Promise { + if (file.size > MAX_PDF_SIZE) { + throw new Error(`PDF too large. Maximum size is ${MAX_PDF_SIZE / 1024 / 1024}MB`); + } + + const pdfjs = await loadPdfJs(); + + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise; + + const textParts: string[] = []; + + 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); + } + + return textParts.join('\n\n'); +} + +// ============================================================================ +// Main Processing Function +// ============================================================================ + +/** + * Process a file and create an attachment + * Handles all file types (image, text, PDF) + */ +export async function processFile(file: File): Promise { + const type = detectFileType(file); + + if (!type) { + return { + success: false, + error: `Unsupported file type: ${file.type || 'unknown'}` + }; + } + + const id = crypto.randomUUID(); + + try { + const baseAttachment: FileAttachment = { + id, + type, + filename: file.name, + mimeType: file.type, + size: file.size + }; + + switch (type) { + case 'image': { + const { base64, previewUrl } = await processImage(file); + return { + success: true, + attachment: { + ...baseAttachment, + base64Data: base64, + previewUrl + } + }; + } + + case 'text': { + const textContent = await readTextFile(file); + return { + success: true, + attachment: { + ...baseAttachment, + textContent + } + }; + } + + case 'pdf': { + const textContent = await extractPdfText(file); + return { + success: true, + attachment: { + ...baseAttachment, + textContent + } + }; + } + + default: + return { + success: false, + error: `Unsupported file type: ${type}` + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error processing file' + }; + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Format file size for display + */ +export 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`; +} + +/** + * Get a file icon based on type + */ +export function getFileIcon(type: AttachmentType): string { + switch (type) { + case 'image': + return '๐Ÿ–ผ๏ธ'; + case 'pdf': + return '๐Ÿ“„'; + case 'text': + return '๐Ÿ“'; + default: + return '๐Ÿ“Ž'; + } +} + +/** + * Format attachment content for inclusion in message + * Prepends file content with a header showing filename + */ +export function formatAttachmentsForMessage(attachments: FileAttachment[]): string { + return attachments + .filter((a) => a.textContent) + .map((a) => `--- ${a.filename} ---\n${a.textContent}`) + .join('\n\n'); +} diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts index 505283f..964083a 100644 --- a/frontend/src/lib/utils/index.ts +++ b/frontend/src/lib/utils/index.ts @@ -30,3 +30,14 @@ export { type Shortcut, type Modifiers } from './keyboard.js'; + +export { + detectFileType, + readTextFile, + processImage, + extractPdfText, + processFile, + formatFileSize, + getFileIcon, + formatAttachmentsForMessage +} from './file-processor.js'; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9066ecd..0679069 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -19,6 +19,15 @@ let ragEnabled = $state(true); let hasKnowledgeBase = $state(false); + // Thinking mode state (for reasoning models) + let thinkingEnabled = $state(true); + + // Derived: Check if selected model supports thinking + const supportsThinking = $derived.by(() => { + const caps = modelsState.selectedCapabilities; + return caps.includes('thinking'); + }); + /** * Get tool definitions for the API call */ @@ -131,6 +140,15 @@ console.log('[NewChat] Using system prompt:', activePrompt.name); } + // Log thinking mode status (now using native API support, not prompt-based) + console.log('[NewChat] Thinking mode check:', { + supportsThinking, + thinkingEnabled, + selectedModel: modelsState.selectedId, + selectedCapabilities: modelsState.selectedCapabilities + }); + // Note: Thinking is now handled via the `think: true` API parameter instead of prompt injection + // Add RAG context if available const ragContext = await retrieveRagContext(content); if (ragContext) { @@ -159,10 +177,32 @@ console.log('[NewChat] Tool names:', tools?.map(t => t.function.name) ?? []); console.log('[NewChat] Using model:', chatModel, '(original:', model, ')'); + // Determine if we should use native thinking mode + const useNativeThinking = supportsThinking && thinkingEnabled; + console.log('[NewChat] Native thinking mode:', useNativeThinking); + + // Track thinking content during streaming + let streamingThinking = ''; + let thinkingClosed = false; + await ollamaClient.streamChatWithCallbacks( - { model: chatModel, messages, tools }, + { model: chatModel, messages, tools, think: useNativeThinking }, { + onThinkingToken: (token) => { + // Accumulate thinking and update the message + if (!streamingThinking) { + // Start the thinking block + chatState.appendToStreaming(''); + } + streamingThinking += token; + chatState.appendToStreaming(token); + }, onToken: (token) => { + // Close thinking block when content starts + if (streamingThinking && !thinkingClosed) { + chatState.appendToStreaming('\n\n'); + thinkingClosed = true; + } chatState.appendToStreaming(token); }, onToolCall: (toolCalls) => { @@ -170,6 +210,12 @@ console.log('[NewChat] Tool calls received:', toolCalls); }, onComplete: async () => { + // Close thinking block if it was opened but not closed (e.g., tool calls without content) + if (streamingThinking && !thinkingClosed) { + chatState.appendToStreaming('\n\n'); + thinkingClosed = true; + } + chatState.finishStreaming(); // Handle tool calls if received @@ -195,6 +241,9 @@ ); await updateConversation(conversationId, {}); conversationsState.update(conversationId, {}); + + // Generate a smarter title in the background (don't await) + generateSmartTitle(conversationId, content, node.message.content); } }, onError: (error) => { @@ -240,7 +289,17 @@ const assistantNode = chatState.messageTree.get(assistantMessageId); if (assistantNode) { - assistantNode.message.content = toolCallInfo + '\n\n' + toolResultContent; + // Preserve any thinking content that was already streamed + const existingContent = assistantNode.message.content || ''; + const newContent = toolCallInfo + '\n\n' + toolResultContent; + + // If there's existing content (like thinking), append tool info after it + if (existingContent.trim()) { + assistantNode.message.content = existingContent + '\n\n' + newContent; + } else { + assistantNode.message.content = newContent; + } + // Store structured tool call data for display assistantNode.message.toolCalls = toolCalls.map(tc => ({ id: crypto.randomUUID(), @@ -249,10 +308,10 @@ })); } - // Persist tool call result + // Persist tool call result (including any thinking content) await addStoredMessage( conversationId, - { role: 'assistant', content: toolCallInfo + '\n\n' + toolResultContent }, + { role: 'assistant', content: assistantNode?.message.content || '' }, userMessageId, assistantMessageId ); @@ -347,8 +406,58 @@ return firstSentence.substring(0, 47) + '...'; } + + /** + * Generate a better title using the LLM after the first response + * Runs in the background, doesn't block the UI + */ + async function generateSmartTitle( + conversationId: string, + userMessage: string, + assistantMessage: string + ): Promise { + try { + // Use a small, fast model for title generation if available, otherwise use selected + const model = modelsState.selectedId; + if (!model) return; + + // Strip thinking blocks from assistant message for cleaner title generation + const cleanedAssistant = assistantMessage + .replace(/[\s\S]*?<\/think>/g, '') + .replace(/[\s\S]*?<\/thinking>/g, '') + .trim(); + + const response = await ollamaClient.chat({ + model, + messages: [ + { + role: 'system', + content: 'Generate a very short, concise title (3-6 words max) for this conversation. Output ONLY the title, no quotes, no explanation.' + }, + { + role: 'user', + content: `User: ${userMessage.substring(0, 200)}\n\nAssistant: ${cleanedAssistant.substring(0, 300)}` + } + ] + }); + + const newTitle = response.message.content + .trim() + .replace(/^["']|["']$/g, '') // Remove quotes + .substring(0, 50); + + if (newTitle && newTitle.length > 0) { + await updateConversation(conversationId, { title: newTitle }); + conversationsState.update(conversationId, { title: newTitle }); + console.log('[NewChat] Updated title to:', newTitle); + } + } catch (error) { + console.error('[NewChat] Failed to generate smart title:', error); + // Silently fail - keep the original title + } + }
- +