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