From 942f364b4f810c1258e44c8e47aab4ec87fcf8f9 Mon Sep 17 00:00:00 2001 From: ckrafft Date: Sun, 19 Jul 2026 00:12:34 +0200 Subject: [PATCH 1/5] server: add read_image tool (#25875) This adds a server-tool that allows vision models to analyze server-side images. This tool is reading only a single file for now: The image data is base64 encoded and passed to the UI, which decodes it, fills the tag and removes the data URI before passing the tool result back to the model. --- tools/server/server-tools.cpp | 88 +++++++++++++++++++ .../ChatMessageToolCallBlock.svelte | 3 + .../ChatMessageToolCallBlockReadImage.svelte | 80 +++++++++++++++++ .../ChatMessageToolCall/parsers/read-image.ts | 46 ++++++++++ tools/ui/src/lib/constants/built-in-tools.ts | 2 + tools/ui/src/lib/enums/tools.enums.ts | 1 + 6 files changed, 220 insertions(+) create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 9eb57abaea15..556b6bc22421 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -1,6 +1,7 @@ #include "server-tools.h" #include +#include "base64.hpp" #include #include @@ -1114,6 +1115,92 @@ static server_tool & find_tool(std::vector> & tools throw std::invalid_argument(string_format("unknown tool \"%s\"", name.c_str())); } +// +// read_image: read an image file and return base64-encoded data with metadata +// + +static constexpr size_t SERVER_TOOL_READ_IMAGE_MAX_SIZE = 16 * 1024 * 1024; // 16 MB + +static std::string get_mime_from_extension(const std::string & path) { + static const std::unordered_map mime_map = { + {".png", "image/png"}, + {".jpg", "image/jpeg"}, + {".jpeg", "image/jpeg"}, + {".webp", "image/webp"}, + {".bmp", "image/bmp"}, + {".tiff", "image/tiff"}, + {".tif", "image/tiff"}, + {".gif", "image/gif"}, + }; + auto ext = fs::path(path).extension().string(); + auto it = mime_map.find(ext); + return (it != mime_map.end()) ? it->second : "application/octet-stream"; +} + +struct server_tool_read_image : server_tool { + server_tool_read_image() { + name = "read_image"; + display_name = "Read image file"; + permission_write = false; + } + + json get_definition() const override { + return { + {"type", "function"}, + {"function", { + {"name", name}, + {"description", "Read an image file from disk and return it as base64-encoded data with metadata."}, + {"parameters", { + {"type", "object"}, + {"properties", { + {"path", {{"type", "string"}, {"description", "Absolute path to the image file."}}}, + }}, + {"required", json::array({"path"})}, + }}, + }}, + }; + } + + json invoke(json params, server_tool::stream *) const override { + std::string path = params.at("path").get(); + + auto io = make_tools_io(params); + + uintmax_t file_size = 0; + if (!io->file_size(path, file_size)) { + return {{"error", "cannot stat file: " + path}}; + } + if (file_size > SERVER_TOOL_READ_IMAGE_MAX_SIZE) { + return {{"error", string_format( + "image too large (%zu bytes, max %zu)", + (size_t)file_size, SERVER_TOOL_READ_IMAGE_MAX_SIZE)}}; + } + + std::string content; + if (!io->read_file(path, content)) { + return {{"error", "failed to open file: " + path}}; + } + + std::string mime = get_mime_from_extension(path); + std::string b64 = base64::encode(content.data(), content.size()); + + // Return as plain_text_response with a data URI line so the UI can + // extract it as an image attachment (via extractBase64Attachments) + std::string data_uri = "data:" + mime + ";base64," + b64; + + return { + {"plain_text_response", + string_format( + "Image: %s\nSize: %zu bytes\nMIME: %s\n%s", + path.c_str(), (size_t)file_size, mime.c_str(), data_uri.c_str())}, + {"path", path}, + {"mime", mime}, + {"size_bytes", (int)file_size}, + }; + } +}; + +// // // public API // @@ -1127,6 +1214,7 @@ static std::vector> build_tools() { tools.push_back(std::make_unique()); tools.push_back(std::make_unique()); tools.push_back(std::make_unique()); + tools.push_back(std::make_unique()); return tools; } diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte index b1daedfc818c..c1aa2e11b062 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -14,6 +14,7 @@ import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte'; import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte'; import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte'; + import ChatMessageToolCallBlockReadImage from './ChatMessageToolCallBlockReadImage.svelte'; import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte'; import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte'; import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; @@ -42,6 +43,8 @@ {:else if section.toolName === BuiltInTool.READ_FILE} +{:else if section.toolName === BuiltInTool.READ_IMAGE} + {:else if section.toolName === BuiltInTool.EDIT_FILE} {:else if section.toolName === BuiltInTool.WRITE_FILE} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte new file mode 100644 index 000000000000..b0f1ac5a6296 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte @@ -0,0 +1,80 @@ + + + + {#snippet titleSnippet()} + Read image + {readImageMeta?.fileName} + {/snippet} + + {#snippet children(_meta, _ctx)} + {#if section.toolResult} + {#if imageAttachment} +
+ {readImageMeta?.fileName +
+ {:else} +
+ Image attachment not found in message extras +
+ {/if} + + {#if readImageMeta?.sizeBytes || readImageMeta?.mimeType} +
+ {#if readImageMeta?.sizeBytes} + Size: {readImageMeta.sizeBytes} bytes + {/if} + {#if readImageMeta?.mimeType} + MIME: {readImageMeta.mimeType} + {/if} +
+ {/if} + + {#if readImageMeta?.path} +
{readImageMeta.path}
+ {/if} + {:else} +
+ Waiting for image data... +
+ {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts new file mode 100644 index 000000000000..3a3352348a39 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts @@ -0,0 +1,46 @@ +import type { AgenticSection } from '$lib/utils'; + +export interface ReadImageMeta { + fileName: string; + path: string; + sizeBytes?: number; + mimeType?: string; +} + +/** + * Parse read_image tool result to extract metadata. + * Expected format (after extractBase64Attachments processing): + * Image: /path/to/file.png + * Size: 12345 bytes + * MIME: image/png + * [Attachment saved: mcp-attachment-xxx.png] + * + * The data URI line is replaced by the attachment marker by + * agenticStore.extractBase64Attachments before storage. + */ +export function parseReadImageMeta(section: AgenticSection): ReadImageMeta | null { + if (!section.toolResult) return null; + + const lines = section.toolResult.split('\n'); + let fileName = ''; + let path = ''; + let sizeBytes: number | undefined; + let mimeType: string | undefined; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('Image: ')) { + path = trimmed.slice('Image: '.length).trim(); + fileName = path.split('/').pop() ?? path; + } else if (trimmed.startsWith('Size: ')) { + const match = trimmed.match(/Size:\s*(\d+)\s*bytes/); + if (match) sizeBytes = parseInt(match[1], 10); + } else if (trimmed.startsWith('MIME: ')) { + mimeType = trimmed.slice('MIME: '.length).trim(); + } + } + + if (!path) return null; + + return { fileName, path, sizeBytes, mimeType }; +} diff --git a/tools/ui/src/lib/constants/built-in-tools.ts b/tools/ui/src/lib/constants/built-in-tools.ts index 0721e6484d62..70c09f217246 100644 --- a/tools/ui/src/lib/constants/built-in-tools.ts +++ b/tools/ui/src/lib/constants/built-in-tools.ts @@ -11,6 +11,7 @@ import type { Component } from 'svelte'; import { Braces, Clock, + Eye, FilePen, FilePlus, FileSearch, @@ -28,6 +29,7 @@ export interface BuiltinToolUiEntry { export const BUILTIN_TOOL_UI: Readonly> = { [BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN }, + [BuiltInTool.READ_IMAGE]: { icon: Eye, label: 'Read image', source: ToolSource.BUILTIN }, [BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN }, [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN }, [BuiltInTool.FILE_GLOB_SEARCH]: { diff --git a/tools/ui/src/lib/enums/tools.enums.ts b/tools/ui/src/lib/enums/tools.enums.ts index 9985e1f4aa98..0a0657e3ee4a 100644 --- a/tools/ui/src/lib/enums/tools.enums.ts +++ b/tools/ui/src/lib/enums/tools.enums.ts @@ -27,6 +27,7 @@ export enum ToolResponseField { */ export enum BuiltInTool { READ_FILE = 'read_file', + READ_IMAGE = 'read_image', EDIT_FILE = 'edit_file', WRITE_FILE = 'write_file', GET_DATETIME = 'get_datetime', From 22560710f154decd23410bed7280960724cc3775 Mon Sep 17 00:00:00 2001 From: ckrafft Date: Mon, 20 Jul 2026 21:07:53 +0200 Subject: [PATCH 2/5] cleanup read_image tool: move magic strings to constants - Add dedicated constants file: tools/ui/src/lib/constants/read-image.ts with PREFIX_IMAGE, PREFIX_SIZE, PREFIX_MIME constants - Use ATTACHMENT_SAVED_REGEX from agentic.ts in ChatMessageToolCallBlockReadImage.svelte - Use NEWLINE constant from code.ts instead of hardcoded '\n' - Use PREFIX_SIZE in regex pattern for size parsing - Add SERVER_TOOL_READ_IMAGE_PREFIX_* constants in C++ server-tools.cpp to match the TypeScript PREFIX_* constants for consistency --- tools/server/server-tools.cpp | 9 +++++++-- .../ChatMessageToolCallBlockReadImage.svelte | 3 ++- .../ChatMessageToolCall/parsers/read-image.ts | 16 +++++++++------- tools/ui/src/lib/constants/read-image.ts | 3 +++ 4 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 tools/ui/src/lib/constants/read-image.ts diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 556b6bc22421..b54bb0469536 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -1120,6 +1120,9 @@ static server_tool & find_tool(std::vector> & tools // static constexpr size_t SERVER_TOOL_READ_IMAGE_MAX_SIZE = 16 * 1024 * 1024; // 16 MB +static constexpr const char* SERVER_TOOL_READ_IMAGE_PREFIX_IMAGE = "Image: "; +static constexpr const char* SERVER_TOOL_READ_IMAGE_PREFIX_SIZE = "Size: "; +static constexpr const char* SERVER_TOOL_READ_IMAGE_PREFIX_MIME = "MIME: "; static std::string get_mime_from_extension(const std::string & path) { static const std::unordered_map mime_map = { @@ -1191,8 +1194,10 @@ struct server_tool_read_image : server_tool { return { {"plain_text_response", string_format( - "Image: %s\nSize: %zu bytes\nMIME: %s\n%s", - path.c_str(), (size_t)file_size, mime.c_str(), data_uri.c_str())}, + "%s%s\n%s%zu bytes\n%s%s\n%s", + SERVER_TOOL_READ_IMAGE_PREFIX_IMAGE, path.c_str(), + SERVER_TOOL_READ_IMAGE_PREFIX_SIZE, (size_t)file_size, + SERVER_TOOL_READ_IMAGE_PREFIX_MIME, mime.c_str(), data_uri.c_str())}, {"path", path}, {"mime", mime}, {"size_bytes", (int)file_size}, diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte index b0f1ac5a6296..a73252fefa7d 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadImage.svelte @@ -1,6 +1,7 @@ - + {#snippet titleSnippet()} - Read image - {readImageMeta?.fileName} + Read media + {readMediaMeta?.fileName} {/snippet} {#snippet children(_meta, _ctx)} {#if section.toolResult} - {#if imageAttachment} + {#if mediaAttachment}
{readImageMeta?.fileName
{:else}
- Image attachment not found in message extras + Media attachment not found in message extras
{/if} - {#if readImageMeta?.sizeBytes || readImageMeta?.mimeType} + {#if readMediaMeta?.sizeBytes || readMediaMeta?.mimeType}
- {#if readImageMeta?.sizeBytes} - Size: {readImageMeta.sizeBytes} bytes + {#if readMediaMeta?.sizeBytes} + Size: {readMediaMeta.sizeBytes} bytes {/if} - {#if readImageMeta?.mimeType} - MIME: {readImageMeta.mimeType} + {#if readMediaMeta?.mimeType} + MIME: {readMediaMeta.mimeType} {/if}
{/if} - {#if readImageMeta?.path} -
{readImageMeta.path}
+ {#if readMediaMeta?.path} +
{readMediaMeta.path}
{/if} {:else}
- Waiting for image data... + Waiting for media data...
{/if} {/snippet} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-media.ts similarity index 74% rename from tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts rename to tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-media.ts index 61d234a04977..cc3fb634258c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-image.ts +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-media.ts @@ -1,8 +1,8 @@ import type { AgenticSection } from '$lib/utils'; import { NEWLINE } from '$lib/constants/code'; -import { PREFIX_IMAGE, PREFIX_SIZE, PREFIX_MIME } from '$lib/constants/read-image'; +import { PREFIX_FILE, PREFIX_SIZE, PREFIX_MIME } from '$lib/constants/read-media'; -export interface ReadImageMeta { +export interface ReadMediaMeta { fileName: string; path: string; sizeBytes?: number; @@ -10,9 +10,9 @@ export interface ReadImageMeta { } /** - * Parse read_image tool result to extract metadata. + * Parse read_media tool result to extract metadata. * Expected format (after extractBase64Attachments processing): - * Image: /path/to/file.png + * File: /path/to/file.png * Size: 12345 bytes * MIME: image/png * [Attachment saved: mcp-attachment-xxx.png] @@ -20,7 +20,7 @@ export interface ReadImageMeta { * The data URI line is replaced by the attachment marker by * agenticStore.extractBase64Attachments before storage. */ -export function parseReadImageMeta(section: AgenticSection): ReadImageMeta | null { +export function parseReadMediaMeta(section: AgenticSection): ReadMediaMeta | null { if (!section.toolResult) return null; const lines = section.toolResult.split(NEWLINE); @@ -31,8 +31,8 @@ export function parseReadImageMeta(section: AgenticSection): ReadImageMeta | nul for (const line of lines) { const trimmed = line.trim(); - if (trimmed.startsWith(PREFIX_IMAGE)) { - path = trimmed.slice(PREFIX_IMAGE.length).trim(); + if (trimmed.startsWith(PREFIX_FILE)) { + path = trimmed.slice(PREFIX_FILE.length).trim(); fileName = path.split('/').pop() ?? path; } else if (trimmed.startsWith(PREFIX_SIZE)) { const match = trimmed.match(new RegExp(`${PREFIX_SIZE}\\s*(\\d+)\\s*bytes`)); diff --git a/tools/ui/src/lib/constants/built-in-tools.ts b/tools/ui/src/lib/constants/built-in-tools.ts index 70c09f217246..da58923c2681 100644 --- a/tools/ui/src/lib/constants/built-in-tools.ts +++ b/tools/ui/src/lib/constants/built-in-tools.ts @@ -29,7 +29,7 @@ export interface BuiltinToolUiEntry { export const BUILTIN_TOOL_UI: Readonly> = { [BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN }, - [BuiltInTool.READ_IMAGE]: { icon: Eye, label: 'Read image', source: ToolSource.BUILTIN }, + [BuiltInTool.READ_MEDIA]: { icon: Eye, label: 'Read media', source: ToolSource.BUILTIN }, [BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN }, [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN }, [BuiltInTool.FILE_GLOB_SEARCH]: { diff --git a/tools/ui/src/lib/constants/read-image.ts b/tools/ui/src/lib/constants/read-media.ts similarity index 65% rename from tools/ui/src/lib/constants/read-image.ts rename to tools/ui/src/lib/constants/read-media.ts index 7346dc013910..7fbda2f1d729 100644 --- a/tools/ui/src/lib/constants/read-image.ts +++ b/tools/ui/src/lib/constants/read-media.ts @@ -1,3 +1,3 @@ -export const PREFIX_IMAGE = 'Image: '; +export const PREFIX_FILE = 'File: '; export const PREFIX_SIZE = 'Size: '; export const PREFIX_MIME = 'MIME: '; diff --git a/tools/ui/src/lib/enums/tools.enums.ts b/tools/ui/src/lib/enums/tools.enums.ts index 0a0657e3ee4a..85ceea506e02 100644 --- a/tools/ui/src/lib/enums/tools.enums.ts +++ b/tools/ui/src/lib/enums/tools.enums.ts @@ -27,7 +27,7 @@ export enum ToolResponseField { */ export enum BuiltInTool { READ_FILE = 'read_file', - READ_IMAGE = 'read_image', + READ_MEDIA = 'read_media', EDIT_FILE = 'edit_file', WRITE_FILE = 'write_file', GET_DATETIME = 'get_datetime', From 95fa8872ede8cd203a2cd7356741886b7aacd44e Mon Sep 17 00:00:00 2001 From: ckrafft Date: Fri, 24 Jul 2026 01:34:07 +0200 Subject: [PATCH 4/5] server: add audio file support to read_media tool - rename parseToolResultWithImages to parseToolResultWithMedia - update all consumers (Default, ExecShellCommand blocks) to use line.media - rename image to media in ToolResultLine and parseToolResultWithMedia, it now holds images and audio - server-tools.cpp: add audio MIME types to get_mime_from_extension() - extend extractBase64Attachments() to detect audio/ MIME types and create AudioFile extras with base64Url - add AUDIO_MIME_TO_EXTENSION to mcp-resource.ts constants alongside IMAGE_MIME_TO_EXTENSION - widen ToolResultLine.image type to include DatabaseMessageExtraAudioFile - ChatMessageToolCallBlockReadMedia renders now