feat: add native thinking mode, file uploads, and model capability icons

Thinking mode:
- Add native Ollama `think: true` API parameter support
- Create ThinkingBlock component with collapsible UI and streaming indicator
- Allow expanding/collapsing thinking blocks during streaming
- Pass showThinking prop through component chain to hide when disabled
- Auto-generate smart chat titles using LLM after first response

File uploads:
- Add FileUpload component supporting images, text files, and PDFs
- Create FilePreview component for non-image attachments
- Add file-processor utility for text extraction and PDF parsing
- Text/PDF content injected as context for all models

Model capabilities:
- Add ModelCapabilityIcons component showing vision/tools/code badges
- Detect model capabilities from name patterns in models store
- Display capability icons in model selector dropdown

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 23:05:08 +01:00
parent a327b24248
commit 4758fa8c52
22 changed files with 1898 additions and 113 deletions

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -1,11 +1,13 @@
<script lang="ts">
/**
* ChatInput - Message input area with auto-growing textarea
* Handles send/stop actions, keyboard shortcuts, and image uploads
* Handles send/stop actions, keyboard shortcuts, and file uploads
*/
import { modelsState } from '$lib/stores';
import ImageUpload from './ImageUpload.svelte';
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatAttachmentsForMessage } from '$lib/utils/file-processor.js';
import FileUpload from './FileUpload.svelte';
interface Props {
onSend?: (content: string, images?: string[]) => void;
@@ -27,11 +29,16 @@
let inputValue = $state('');
let textareaElement: HTMLTextAreaElement | null = $state(null);
// Image state
// Image state (for vision models)
let pendingImages = $state<string[]>([]);
// File attachment state (text/PDF for all models)
let pendingAttachments = $state<FileAttachment[]>([]);
// Derived state
const hasContent = $derived(inputValue.trim().length > 0 || pendingImages.length > 0);
const hasContent = $derived(
inputValue.trim().length > 0 || pendingImages.length > 0 || pendingAttachments.length > 0
);
const canSend = $derived(hasContent && !disabled && !isStreaming);
const showStopButton = $derived(isStreaming);
@@ -76,12 +83,21 @@
function handleSend(): void {
if (!canSend) return;
const content = inputValue.trim();
let content = inputValue.trim();
const images = pendingImages.length > 0 ? [...pendingImages] : undefined;
// Clear input and images
// Prepend file attachments content to the message
if (pendingAttachments.length > 0) {
const attachmentContent = formatAttachmentsForMessage(pendingAttachments);
if (attachmentContent) {
content = attachmentContent + (content ? '\n\n' + content : '');
}
}
// Clear input, images, and attachments
inputValue = '';
pendingImages = [];
pendingAttachments = [];
// Reset textarea height
if (textareaElement) {
@@ -102,12 +118,19 @@
}
/**
* Handle image changes from ImageUpload
* Handle image changes from FileUpload
*/
function handleImagesChange(images: string[]): void {
pendingImages = images;
}
/**
* Handle attachment changes from FileUpload
*/
function handleAttachmentsChange(attachments: FileAttachment[]): void {
pendingAttachments = attachments;
}
/**
* Focus the textarea
*/
@@ -124,36 +147,56 @@
</script>
<div class="relative space-y-2">
<!-- Image upload area (only shown for vision models) -->
{#if isVisionModel}
<ImageUpload
images={pendingImages}
onImagesChange={handleImagesChange}
{disabled}
/>
{/if}
<!-- File upload area (images for vision models, text/PDFs for all) -->
<FileUpload
images={pendingImages}
onImagesChange={handleImagesChange}
attachments={pendingAttachments}
onAttachmentsChange={handleAttachmentsChange}
supportsVision={isVisionModel}
{disabled}
/>
<div
class="flex items-end gap-3 rounded-2xl border border-slate-700/50 bg-slate-800/80 p-3 backdrop-blur transition-all focus-within:border-slate-600 focus-within:bg-slate-800"
>
<!-- Image indicator badge (for vision models) -->
{#if isVisionModel && pendingImages.length > 0}
<div class="flex h-9 items-center">
<span class="flex items-center gap-1.5 rounded-lg bg-violet-500/20 px-2.5 py-1 text-xs font-medium text-violet-300">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
clip-rule="evenodd"
/>
</svg>
{pendingImages.length}
</span>
<!-- Attachment indicators -->
{#if pendingImages.length > 0 || pendingAttachments.length > 0}
<div class="flex h-9 items-center gap-1.5">
{#if pendingImages.length > 0}
<span class="flex items-center gap-1 rounded-lg bg-violet-500/20 px-2 py-1 text-xs font-medium text-violet-300" title="Images attached">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
clip-rule="evenodd"
/>
</svg>
{pendingImages.length}
</span>
{/if}
{#if pendingAttachments.length > 0}
<span class="flex items-center gap-1 rounded-lg bg-emerald-500/20 px-2 py-1 text-xs font-medium text-emerald-300" title="Files attached">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clip-rule="evenodd"
/>
</svg>
{pendingAttachments.length}
</span>
{/if}
</div>
{/if}
@@ -220,9 +263,11 @@
<kbd class="rounded bg-slate-800 px-1 py-0.5 font-mono">Enter</kbd> send
<span class="mx-1.5 text-slate-700">·</span>
<kbd class="rounded bg-slate-800 px-1 py-0.5 font-mono">Shift+Enter</kbd> new line
<span class="mx-1.5 text-slate-700">·</span>
{#if isVisionModel}
<span class="mx-1.5 text-slate-700">·</span>
<span class="text-violet-500/70">images supported</span>
<span class="text-violet-500/70">images</span>
<span class="mx-1 text-slate-700">+</span>
{/if}
<span class="text-slate-500">files supported</span>
</p>
</div>

View File

@@ -36,9 +36,16 @@
mode?: 'new' | 'conversation';
onFirstMessage?: (content: string, images?: string[]) => Promise<void>;
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<string | null>(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('<think>');
}
streamingThinking += token;
chatState.appendToStreaming(token);
},
onToken: (token) => {
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\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('</think>\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 @@
<MessageList
onRegenerate={handleRegenerate}
onEditMessage={handleEditMessage}
showThinking={thinkingEnabled}
/>
</div>
{:else}
@@ -645,6 +704,29 @@
</div>
{/if}
<!-- Chat options bar (thinking mode toggle) -->
{#if supportsThinking}
<div class="flex items-center justify-end gap-3 px-4 pt-3">
<label class="flex cursor-pointer items-center gap-2 text-xs text-slate-400">
<span class="flex items-center gap-1">
<span class="text-amber-400">🧠</span>
Thinking mode
</span>
<button
type="button"
role="switch"
aria-checked={thinkingEnabled}
onclick={() => (thinkingEnabled = !thinkingEnabled)}
class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-slate-900 {thinkingEnabled ? 'bg-amber-600' : 'bg-slate-600'}"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {thinkingEnabled ? 'translate-x-4' : 'translate-x-0'}"
></span>
</button>
</label>
</div>
{/if}
<div class="px-4 pb-4 pt-2">
<ChatInput
onSend={handleSendMessage}

View File

@@ -0,0 +1,111 @@
<script lang="ts">
/**
* 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;
}
}
</script>
<div
class="group relative flex items-start gap-3 rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-colors hover:bg-slate-800"
>
<!-- File icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-700/50 text-lg">
{getFileIcon(attachment.type)}
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-slate-200" title={attachment.filename}>
{attachment.filename}
</p>
<p class="text-xs text-slate-500">
{formatFileSize(attachment.size)}
{#if attachment.type === 'pdf'}
<span class="text-slate-600">·</span>
<span class="text-violet-400">PDF</span>
{/if}
</p>
</div>
<!-- Remove button (only when not readonly) -->
{#if !readonly && onRemove}
<button
type="button"
onclick={handleRemove}
class="shrink-0 rounded p-1 text-slate-500 opacity-0 transition-all hover:bg-red-900/30 hover:text-red-400 group-hover:opacity-100"
aria-label="Remove file"
title="Remove"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
{/if}
</div>
<!-- Content preview (expandable) -->
{#if hasContent}
<button
type="button"
onclick={toggleExpand}
class="mt-2 w-full text-left"
>
<div
class="rounded border border-slate-700/50 bg-slate-900/50 p-2 text-xs text-slate-400 transition-colors hover:border-slate-600"
>
{#if isExpanded}
<pre class="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono">{attachment.textContent}</pre>
{:else}
<p class="truncate font-mono">{previewText}</p>
{/if}
<p class="mt-1 text-[10px] text-slate-600">
{isExpanded ? 'Click to collapse' : 'Click to expand'}
</p>
</div>
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,243 @@
<script lang="ts">
/**
* FileUpload.svelte - Unified file upload for chat input
* Handles both images (for vision models) and text/PDF files (for all models)
* - Images: shown only for vision-capable models
* - Text/PDF: available for all models (content prepended to message)
*/
import type { FileAttachment } from '$lib/types/attachment.js';
import { processFile, formatFileSize } from '$lib/utils/file-processor.js';
import { isImageMimeType } from '$lib/types/attachment.js';
import ImageUpload from './ImageUpload.svelte';
import FilePreview from './FilePreview.svelte';
interface Props {
/** Images for vision models (base64 without prefix) */
images: string[];
onImagesChange: (images: string[]) => void;
/** Text/PDF file attachments */
attachments: FileAttachment[];
onAttachmentsChange: (attachments: FileAttachment[]) => void;
/** Whether the model supports vision */
supportsVision?: boolean;
/** Whether upload is disabled */
disabled?: boolean;
}
const {
images,
onImagesChange,
attachments,
onAttachmentsChange,
supportsVision = false,
disabled = false
}: Props = $props();
// Processing state
let isProcessing = $state(false);
let errorMessage = $state<string | null>(null);
let fileInputRef: HTMLInputElement | null = $state(null);
// Derived states
const hasAttachments = $derived(attachments.length > 0);
const hasImages = $derived(images.length > 0);
const hasContent = $derived(hasAttachments || hasImages);
// Accept string for file input (text files and PDFs, no images - those go through ImageUpload)
const fileAccept = '.txt,.md,.json,.js,.ts,.py,.go,.rs,.java,.c,.cpp,.rb,.php,.sh,.sql,.css,.html,.xml,.yaml,.yml,.toml,application/pdf,text/*';
/**
* Handle file selection from input
*/
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
await processFiles(Array.from(input.files));
input.value = ''; // Reset to allow same file selection
}
/**
* Process multiple files
*/
async function processFiles(files: File[]) {
isProcessing = true;
errorMessage = null;
const newAttachments: FileAttachment[] = [];
const errors: string[] = [];
for (const file of files) {
// Skip images - they're handled by ImageUpload
if (isImageMimeType(file.type)) {
continue;
}
const result = await processFile(file);
if (result.success) {
newAttachments.push(result.attachment);
} else {
errors.push(`${file.name}: ${result.error}`);
}
}
if (newAttachments.length > 0) {
onAttachmentsChange([...attachments, ...newAttachments]);
}
if (errors.length > 0) {
errorMessage = errors.join('; ');
setTimeout(() => {
errorMessage = null;
}, 5000);
}
isProcessing = false;
}
/**
* Remove an attachment by ID
*/
function removeAttachment(id: string) {
onAttachmentsChange(attachments.filter((a) => a.id !== id));
}
/**
* Open file picker
*/
function openFilePicker() {
if (!disabled && fileInputRef) {
fileInputRef.click();
}
}
/**
* Handle paste events for file attachments
*/
function handlePaste(event: ClipboardEvent) {
if (disabled) return;
const items = event.clipboardData?.items;
if (!items) return;
const files: 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);
}
}
}
if (files.length > 0) {
// Don't prevent default if we have no files to process
// (let ImageUpload handle images)
processFiles(files);
}
}
// Set up paste listener
$effect(() => {
if (!disabled) {
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}
});
</script>
<div class="space-y-3">
<!-- Image upload section (only for vision models) -->
{#if supportsVision}
<ImageUpload {images} {onImagesChange} {disabled} />
{/if}
<!-- File attachments preview -->
{#if hasAttachments}
<div class="space-y-2">
{#each attachments as attachment (attachment.id)}
<FilePreview {attachment} onRemove={removeAttachment} />
{/each}
</div>
{/if}
<!-- Add files button -->
<div class="flex items-center gap-2">
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
type="file"
accept={fileAccept}
multiple
class="hidden"
onchange={handleFileSelect}
{disabled}
/>
<!-- Attach files button -->
<button
type="button"
onclick={openFilePicker}
disabled={disabled || isProcessing}
class="flex items-center gap-1.5 rounded-lg border border-slate-700/50 bg-slate-800/50 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-300 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if isProcessing}
<svg
class="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Processing...</span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4"
>
<path
fill-rule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clip-rule="evenodd"
/>
</svg>
<span>Attach files</span>
{/if}
</button>
<!-- File type hint -->
<span class="text-[10px] text-slate-600">
{#if supportsVision}
Images, text files, PDFs
{:else}
Text files, PDFs (content will be included in message)
{/if}
</span>
</div>
<!-- Error message -->
{#if errorMessage}
<div class="rounded-lg bg-red-900/20 px-3 py-2 text-xs text-red-400">
{errorMessage}
</div>
{/if}
</div>

View File

@@ -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 <thinking>...</thinking> and <think>...</think> (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 <think> 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);
</script>
<div class="message-content">
@@ -255,7 +325,12 @@
<!-- Render content parts -->
{#each contentParts as part, index (index)}
{#if part.type === 'code'}
{#if part.type === 'thinking'}
<ThinkingBlock
content={part.content}
inProgress={isThinkingInProgress && index === 0}
/>
{:else if part.type === 'code'}
<div class="my-3 space-y-3">
<CodeBlock
code={part.content}

View File

@@ -16,6 +16,8 @@
branchInfo: BranchInfo | null;
isStreaming?: boolean;
isLast?: boolean;
/** Whether to show thinking blocks in messages */
showThinking?: boolean;
onBranchSwitch?: (direction: 'prev' | 'next') => 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}

View File

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

View File

@@ -0,0 +1,152 @@
<script lang="ts">
/**
* ThinkingBlock.svelte - Collapsible display for model reasoning/thinking
* Shows thinking content in a muted, collapsed-by-default section
* User can expand/collapse at any time, including while thinking is in progress
*/
import { marked } from 'marked';
import DOMPurify from 'dompurify';
interface Props {
content: string;
defaultExpanded?: boolean;
/** Whether thinking is currently streaming */
inProgress?: boolean;
}
const { content, defaultExpanded = false, inProgress = false }: Props = $props();
let isExpanded = $state(defaultExpanded);
// Keep collapsed during and after streaming - user can expand manually if desired
/**
* Render markdown to sanitized HTML
*/
function renderMarkdown(text: string): string {
const html = marked.parse(text, {
async: false,
gfm: true,
breaks: true
}) as string;
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'del',
'ul', 'ol', 'li', 'code', 'pre', 'blockquote'
],
ALLOWED_ATTR: []
});
}
function toggle() {
isExpanded = !isExpanded;
}
</script>
<div class="my-3 rounded-lg border border-amber-900/30 bg-amber-950/20 {inProgress ? 'ring-1 ring-amber-500/30' : ''}">
<!-- Header with toggle -->
<button
type="button"
onclick={toggle}
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-amber-400/80 transition-colors hover:bg-amber-900/20"
>
<!-- Expand/Collapse chevron -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-4 w-4 transition-transform {isExpanded ? 'rotate-90' : ''}"
>
<path
fill-rule="evenodd"
d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z"
clip-rule="evenodd"
/>
</svg>
<!-- Thinking indicator with optional spinner -->
<span class="flex items-center gap-1.5">
{#if inProgress}
<!-- Animated spinner -->
<svg
class="h-3.5 w-3.5 animate-spin text-amber-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<span>🧠</span>
{/if}
<span class="font-medium">
{#if inProgress}
Thinking...
{:else}
Reasoning
{/if}
</span>
</span>
<span class="text-amber-500/50">
{isExpanded ? 'Click to collapse' : 'Click to expand'}
</span>
</button>
<!-- Thinking content (collapsible) -->
{#if isExpanded}
<div class="border-t border-amber-900/30 px-3 py-2">
<div class="thinking-content prose prose-sm prose-invert prose-amber max-w-none text-sm text-amber-200/70">
{@html renderMarkdown(content)}
{#if inProgress}
<span class="thinking-cursor"></span>
{/if}
</div>
</div>
{/if}
</div>
<style>
/* Blinking cursor for streaming */
.thinking-cursor {
display: inline-block;
width: 2px;
height: 1em;
background-color: rgb(251 191 36 / 0.7);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Prose styling for thinking content */
.thinking-content :global(p) {
margin: 0.5rem 0;
}
.thinking-content :global(p:first-child) {
margin-top: 0;
}
.thinking-content :global(p:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
/**
* ModelCapabilityIcons.svelte - Compact capability icons for models
* Shows icons for vision, tools, code, etc. with hover tooltips
* Fetches capabilities from API on mount if not cached
*/
import { modelsState, CAPABILITY_INFO } from '$lib/stores';
import type { OllamaCapability } from '$lib/ollama/types.js';
interface Props {
modelName: string;
/** Show smaller icons for dropdown items */
compact?: boolean;
}
const { modelName, compact = false }: Props = $props();
// Capabilities to display (exclude 'completion' as it's universal)
const DISPLAY_CAPABILITIES: OllamaCapability[] = [
'vision',
'tools',
'code',
'thinking',
'uncensored',
'cloud'
];
// Get cached capabilities
const capabilities = $derived(modelsState.getCapabilities(modelName) ?? []);
// Filter to displayable capabilities
const displayCapabilities = $derived(
capabilities.filter((cap) => DISPLAY_CAPABILITIES.includes(cap as OllamaCapability))
);
// Fetch capabilities if not cached
$effect(() => {
if (!modelsState.getCapabilities(modelName)) {
modelsState.fetchCapabilities(modelName);
}
});
/** Get icon info for a capability */
function getCapabilityInfo(cap: string) {
return CAPABILITY_INFO[cap] ?? { label: cap, icon: '?', color: 'slate' };
}
/** Get human-readable description for capability */
function getCapabilityDescription(cap: string): string {
const descriptions: Record<string, string> = {
vision: 'Image input support',
tools: 'Function calling',
code: 'Optimized for programming',
thinking: 'Reasoning/Chain-of-Thought',
uncensored: 'No guardrails',
cloud: 'Cloud offloading'
};
return descriptions[cap] ?? 'Unknown capability';
}
/** Get color class based on capability */
function getColorClasses(color: string): { bg: string; text: string } {
const colors: Record<string, { bg: string; text: string }> = {
purple: { bg: 'bg-purple-900/50', text: 'text-purple-300' },
blue: { bg: 'bg-blue-900/50', text: 'text-blue-300' },
emerald: { bg: 'bg-emerald-900/50', text: 'text-emerald-300' },
amber: { bg: 'bg-amber-900/50', text: 'text-amber-300' },
red: { bg: 'bg-red-900/50', text: 'text-red-300' },
sky: { bg: 'bg-sky-900/50', text: 'text-sky-300' },
slate: { bg: 'bg-slate-800', text: 'text-slate-400' }
};
return colors[color] ?? colors.slate;
}
</script>
{#if displayCapabilities.length > 0}
<div class="flex items-center gap-0.5">
{#each displayCapabilities as cap (cap)}
{@const info = getCapabilityInfo(cap)}
{@const colors = getColorClasses(info.color)}
<span
class="inline-flex items-center justify-center rounded-full {colors.bg} {colors.text} {compact
? 'h-4 w-4 text-[10px]'
: 'h-5 w-5 text-xs'}"
title="{info.label}: {getCapabilityDescription(cap)}"
>
{info.icon}
</span>
{/each}
</div>
{/if}

View File

@@ -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 @@
<div class="flex flex-col items-start">
<div class="flex items-center gap-1.5">
<span class="text-slate-200">{modelsState.selected.name}</span>
<!-- Vision indicator badge -->
{#if modelsState.selectedSupportsVision}
<span class="inline-flex items-center gap-0.5 rounded-full bg-purple-900/50 px-1.5 py-0.5 text-[10px] font-medium text-purple-300" title="Vision capable - supports image input">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-2.5 w-2.5"
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path fill-rule="evenodd" d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
Vision
</span>
{/if}
<!-- Capability icons -->
<ModelCapabilityIcons modelName={modelsState.selected.name} />
</div>
<span class="text-xs text-slate-500">{modelsState.selected.details.parameter_size}</span>
</div>
@@ -166,20 +147,8 @@
<span class="text-sm" class:text-slate-200={modelsState.selectedId !== model.name}>
{model.name}
</span>
<!-- Vision indicator for models in dropdown -->
{#if isVisionCapable(model.name)}
<span class="inline-flex items-center rounded-full bg-purple-900/50 px-1 py-0.5 text-[9px] font-medium text-purple-300" title="Vision capable">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="h-2 w-2"
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path fill-rule="evenodd" d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
</svg>
</span>
{/if}
<!-- Capability icons for models in dropdown -->
<ModelCapabilityIcons modelName={model.name} compact />
</div>
<span class="text-xs text-slate-500">
{model.details.parameter_size}

View File

@@ -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 */

View File

@@ -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<OllamaChatStreamChunk>();
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<StreamChatResult> {
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);

View File

@@ -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<string, unknown>;
modified_at: string;
/** Model capabilities (vision, tools, code, etc.) */
capabilities?: OllamaCapability[];
}
// ============================================================================

View File

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

View File

@@ -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<string, { label: string; icon: string; color: string }> = {
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<string | null>(null);
// Capabilities cache: modelName -> capabilities array
private capabilitiesCache = $state<Map<string, OllamaCapability[]>>(new Map());
private capabilitiesFetching = new Set<string>();
// 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<OllamaCapability[]> {
// 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<void> {
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 */

View File

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

View File

@@ -2,6 +2,7 @@
* Type exports
*/
export * from './attachment.js';
export * from './chat.js';
export * from './conversation.js';
export * from './model.js';

View File

@@ -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<string> {
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<typeof import('pdfjs-dist')> {
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<string> {
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<ProcessFileOutcome> {
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');
}

View File

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

View File

@@ -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('<think>');
}
streamingThinking += token;
chatState.appendToStreaming(token);
},
onToken: (token) => {
// Close thinking block when content starts
if (streamingThinking && !thinkingClosed) {
chatState.appendToStreaming('</think>\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('</think>\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<void> {
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(/<think>[\s\S]*?<\/think>/g, '')
.replace(/<thinking>[\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
}
}
</script>
<div class="flex h-full flex-col">
<ChatWindow mode="new" onFirstMessage={handleFirstMessage} />
<ChatWindow mode="new" onFirstMessage={handleFirstMessage} bind:thinkingEnabled />
</div>