Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions tools/server/server-tools.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "server-tools.h"

#include <sheredom/subprocess.h>
#include "base64.hpp"

#include <filesystem>
#include <fstream>
Expand Down Expand Up @@ -1061,6 +1062,107 @@ struct server_tool_get_datetime : server_tool {
}
};

//
// read_media: read a media file (image or audio) and return base64-encoded data with metadata

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not the good placement in the file, move it to after the last tool definition above

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, indeed. section has been moved

//

static constexpr size_t SERVER_TOOL_READ_MEDIA_MAX_SIZE = 16 * 1024 * 1024; // 16 MB
static constexpr const char* SERVER_TOOL_READ_MEDIA_PREFIX_FILE = "File: ";
static constexpr const char* SERVER_TOOL_READ_MEDIA_PREFIX_SIZE = "Size: ";
static constexpr const char* SERVER_TOOL_READ_MEDIA_PREFIX_MIME = "MIME: ";

static std::string get_mime_from_extension(const std::string & path) {
static const std::unordered_map<std::string, std::string> mime_map = {
// Images
{".png", "image/png"},
{".jpg", "image/jpeg"},
{".jpeg", "image/jpeg"},
{".webp", "image/webp"},
{".bmp", "image/bmp"},
{".tiff", "image/tiff"},
{".tif", "image/tiff"},
{".gif", "image/gif"},
// Audio
{".mp3", "audio/mpeg"},
{".wav", "audio/wav"},
{".ogg", "audio/ogg"},
{".flac", "audio/flac"},
{".m4a", "audio/mp4"},
{".opus", "audio/opus"},
};
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_media : server_tool {
server_tool_read_media() {
name = "read_media";
display_name = "Read media file";
permission_write = false;
}

json get_definition() const override {
return {
{"type", "function"},
{"function", {
{"name", name},
{"description", "Read a media file (audio, image) from disk and return it as base64-encoded data with metadata."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Absolute path to the media file."}}},
}},
{"required", json::array({"path"})},
}},
}},
};
}

json invoke(json params, server_tool::stream *) const override {
std::string path = params.at("path").get<std::string>();

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_MEDIA_MAX_SIZE) {
return {{"error", string_format(
"media file too large (%zu bytes, max %zu)",
(size_t)file_size, SERVER_TOOL_READ_MEDIA_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 attachment (via extractBase64Attachments)
// and replace it with some placeholder
std::string data_uri = "data:" + mime + ";base64," + b64;

return {
{"plain_text_response",
string_format(
"%s%s\n%s%zu bytes\n%s%s\n%s",
SERVER_TOOL_READ_MEDIA_PREFIX_FILE, path.c_str(),
SERVER_TOOL_READ_MEDIA_PREFIX_SIZE, (size_t)file_size,
SERVER_TOOL_READ_MEDIA_PREFIX_MIME, mime.c_str(),
data_uri.c_str())},
{"path", path},
{"mime", mime},
{"size_bytes", (int)file_size},
};
}
};


struct server_tool_stream_result : server_task_result {
std::string chunk;
bool done = false;
Expand Down Expand Up @@ -1114,6 +1216,7 @@ static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools
throw std::invalid_argument(string_format("unknown tool \"%s\"", name.c_str()));
}

//
//
// public API
//
Expand All @@ -1127,6 +1230,7 @@ static std::vector<std::unique_ptr<server_tool>> build_tools() {
tools.push_back(std::make_unique<server_tool_write_file>());
tools.push_back(std::make_unique<server_tool_edit_file>());
tools.push_back(std::make_unique<server_tool_get_datetime>());
tools.push_back(std::make_unique<server_tool_read_media>());
return tools;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
import ChatMessageToolCallBlockReadMedia from './ChatMessageToolCallBlockReadMedia.svelte';
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
Expand Down Expand Up @@ -42,6 +43,8 @@
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
{:else if section.toolName === BuiltInTool.READ_FILE}
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.READ_MEDIA}
<ChatMessageToolCallBlockReadMedia {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.EDIT_FILE}
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
{:else if section.toolName === BuiltInTool.WRITE_FILE}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import {
classifyToolResult,
formatJsonPretty,
parseToolResultWithImages,
parseToolResultWithMedia,
type AgenticSection,
type ToolResultLine
} from '$lib/utils';
Expand All @@ -31,7 +31,7 @@
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');

const parsedLines: ToolResultLine[] = $derived(
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
);
const outputKind = $derived(classifyToolResult(section.toolResult));
</script>
Expand Down Expand Up @@ -105,14 +105,23 @@
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
{line.text}
</div>
{#if line.image}
<img
src={line.image.base64Url}
alt={line.image.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{#if line.media}
{#if line.media.type === 'AUDIO'}
<div class="mt-2 mb-2">
<audio controls class="w-full rounded-lg">
<source src={line.media.base64Url} type={line.media.mimeType ?? 'audio/mpeg'} />
Your browser does not support the audio element.
</audio>
</div>
{:else}
<img
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{/if}
{/each}
</div>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
isExitCodeSummaryLine,
parseExecShellCommandError,
parseExecShellCommandExitStatus,
parseToolResultWithImages,
parseToolResultWithMedia,
type AgenticSection,
type ExecShellExitStatus,
type ToolResultLine
Expand Down Expand Up @@ -51,7 +51,7 @@
);

const parsedLines: ToolResultLine[] = $derived(
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
);

// Drop the trailing "[exit code: N]" line - rendered as a colored
Expand Down Expand Up @@ -200,10 +200,10 @@
>
{#each outputLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.image}
{#if line.media}
<img
src={line.image.base64Url}
alt={line.image.name}
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<script lang="ts">
import { Eye } from '@lucide/svelte';
import { AttachmentType } from '$lib/enums';
import { ATTACHMENT_SAVED_REGEX } from '$lib/constants/agentic';
import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile, DatabaseMessageExtraAudioFile } from '$lib/types';
import { type AgenticSection } from '$lib/utils';
import { parseReadMediaMeta } from './parsers/read-media';
import ToolCallBlock from './ToolCallBlock.svelte';

interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}

let { section, open, isStreaming, onToggle }: Props = $props();

const readMediaMeta = $derived(parseReadMediaMeta(section));

// Find the attachment from toolResultExtras (attached to the tool result message.
// The extractBase64Attachments function in agentic.svelte.ts replaces the data URI line
// with [Attachment saved: name] and stores the base64 as an extra.
const mediaAttachment = $derived.by(() => {
const extras = section.toolResultExtras;
if (!extras || extras.length === 0) return null;
// Extract the attachment name from the cleaned result text
const match = section.toolResult?.match(ATTACHMENT_SAVED_REGEX);
if (!match) return null;
const attachmentName = match[1];
return extras.find(
(e): e is DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile =>
(e.type === AttachmentType.IMAGE || e.type === AttachmentType.AUDIO) &&
e.name === attachmentName
) ?? null;
});

const isAudio = $derived(mediaAttachment?.type === AttachmentType.AUDIO);
</script>

<ToolCallBlock {section} {open} {isStreaming} meta={readMediaMeta} {onToggle}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Read media </span>
<span class="font-mono">{readMediaMeta?.fileName}</span>
{/snippet}

{#snippet children(_meta, _ctx)}
{#if section.toolResult}
{#if mediaAttachment}
{#if isAudio}
<div class="mt-2">
<audio controls class="w-full rounded-lg">
<source src={mediaAttachment.base64Url} type={readMediaMeta?.mimeType ?? 'audio/mpeg'} />
Your browser does not support the audio element.
</audio>
</div>
{:else}
<div class="mt-2">
<img
src={mediaAttachment.base64Url}
alt={readMediaMeta?.fileName ?? 'media'}
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
/>
</div>
{/if}
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Media attachment not found in message extras
</div>
{/if}

{#if readMediaMeta?.sizeBytes || readMediaMeta?.mimeType}
<div class="mt-2 flex gap-4 text-xs text-muted-foreground">
{#if readMediaMeta?.sizeBytes}
<span>Size: {readMediaMeta.sizeBytes} bytes</span>
{/if}
{#if readMediaMeta?.mimeType}
<span>MIME: {readMediaMeta.mimeType}</span>
{/if}
</div>
{/if}

{#if readMediaMeta?.path}
<div class="mt-1 text-xs text-muted-foreground/60 font-mono">{readMediaMeta.path}</div>
{/if}
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Waiting for media data...
</div>
{/if}
{/snippet}
</ToolCallBlock>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { AgenticSection } from '$lib/utils';
import { NEWLINE } from '$lib/constants/code';
import { PREFIX_FILE, PREFIX_SIZE, PREFIX_MIME } from '$lib/constants/read-media';

export interface ReadMediaMeta {
fileName: string;
path: string;
sizeBytes?: number;
mimeType?: string;
}

/**
* Parse read_media tool result to extract metadata.
* Expected format (after extractBase64Attachments processing):
* File: /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 parseReadMediaMeta(section: AgenticSection): ReadMediaMeta | null {
if (!section.toolResult) return null;

const lines = section.toolResult.split(NEWLINE);
let fileName = '';
let path = '';
let sizeBytes: number | undefined;
let mimeType: string | undefined;

for (const line of lines) {
const trimmed = line.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`));
if (match) sizeBytes = parseInt(match[1], 10);
} else if (trimmed.startsWith(PREFIX_MIME)) {
mimeType = trimmed.slice(PREFIX_MIME.length).trim();
}
}

if (!path) return null;

return { fileName, path, sizeBytes, mimeType };
}
2 changes: 2 additions & 0 deletions tools/ui/src/lib/constants/built-in-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Component } from 'svelte';
import {
Braces,
Clock,
Eye,
FilePen,
FilePlus,
FileSearch,
Expand All @@ -28,6 +29,7 @@ export interface BuiltinToolUiEntry {

export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>> = {
[BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', 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]: {
Expand Down
Loading