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:
263
frontend/package-lock.json
generated
263
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
111
frontend/src/lib/components/chat/FilePreview.svelte
Normal file
111
frontend/src/lib/components/chat/FilePreview.svelte
Normal 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>
|
||||
243
frontend/src/lib/components/chat/FileUpload.svelte
Normal file
243
frontend/src/lib/components/chat/FileUpload.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
152
frontend/src/lib/components/chat/ThinkingBlock.svelte
Normal file
152
frontend/src/lib/components/chat/ThinkingBlock.svelte
Normal 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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 */
|
||||
|
||||
166
frontend/src/lib/types/attachment.ts
Normal file
166
frontend/src/lib/types/attachment.ts
Normal 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;
|
||||
@@ -2,6 +2,7 @@
|
||||
* Type exports
|
||||
*/
|
||||
|
||||
export * from './attachment.js';
|
||||
export * from './chat.js';
|
||||
export * from './conversation.js';
|
||||
export * from './model.js';
|
||||
|
||||
312
frontend/src/lib/utils/file-processor.ts
Normal file
312
frontend/src/lib/utils/file-processor.ts
Normal 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');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user