feat: improve tool calling UI/UX

ToolCallDisplay improvements:
- Tool-specific icons and gradient colors (location, search, fetch, time, calc)
- Human-readable argument formatting instead of raw JSON
- Collapsible details with expand/collapse animation
- Contextual summaries (e.g., "Searching: query")

New ToolResultDisplay component:
- Beautiful location results with city/country display
- Web search results as clickable cards with ranks
- Error states with distinct red styling
- Automatic JSON detection and formatting

MessageContent improvements:
- Detect and parse tool results in message content
- Hide redundant "Called tool:" text (shown via ToolCallDisplay)
- Clean separation of text, code, and tool results

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 21:09:40 +01:00
parent b99fc3d0c1
commit f5fa9121f4
3 changed files with 369 additions and 54 deletions

View File

@@ -8,6 +8,7 @@
import DOMPurify from 'dompurify';
import CodeBlock from './CodeBlock.svelte';
import HtmlPreview from './HtmlPreview.svelte';
import ToolResultDisplay from './ToolResultDisplay.svelte';
import { base64ToDataUrl } from '$lib/ollama/image-processor';
interface Props {
@@ -20,11 +21,17 @@
// Pattern to find fenced code blocks
const CODE_BLOCK_PATTERN = /```(\w+)?\n([\s\S]*?)```/g;
// Pattern to detect tool execution results
const TOOL_RESULT_PATTERN = /Tool execution results:\s*\n(Tool (?:result|error):[\s\S]*?)(?=\n\nBased on these results|$)/;
// Pattern for "Called tool:" text (redundant with ToolCallDisplay)
const CALLED_TOOL_PATTERN = /Called tool:\s*\w+\([^)]*\)\s*\n*/g;
// Languages that should show a preview
const PREVIEW_LANGUAGES = ['html', 'htm'];
interface ContentPart {
type: 'text' | 'code';
type: 'text' | 'code' | 'tool-result';
content: string;
language?: string;
showPreview?: boolean;
@@ -39,7 +46,52 @@
let modalImage = $state<string | null>(null);
/**
* Parse content into parts (text and code blocks)
* Clean redundant "Called tool:" text (shown via ToolCallDisplay)
*/
function cleanCalledToolText(text: string): string {
return text.replace(CALLED_TOOL_PATTERN, '').trim();
}
/**
* Check if text contains tool execution results
*/
function containsToolResult(text: string): boolean {
return text.includes('Tool execution results:') || text.includes('Tool result:') || text.includes('Tool error:');
}
/**
* Parse a text section for tool results
*/
function parseTextForToolResults(text: string): ContentPart[] {
const parts: ContentPart[] = [];
// Check for tool execution results pattern
const toolMatch = text.match(TOOL_RESULT_PATTERN);
if (toolMatch) {
const beforeTool = text.slice(0, toolMatch.index);
const toolContent = toolMatch[1];
const afterTool = text.slice((toolMatch.index || 0) + toolMatch[0].length);
if (beforeTool.trim()) {
parts.push({ type: 'text', content: beforeTool });
}
parts.push({ type: 'tool-result', content: toolContent });
if (afterTool.trim()) {
// Recursively parse remaining content
parts.push(...parseTextForToolResults(afterTool));
}
return parts;
}
// No tool result found, return as text
if (text.trim()) {
parts.push({ type: 'text', content: text });
}
return parts;
}
/**
* Parse content into parts (text, code blocks, and tool results)
*/
function parseContent(text: string): ContentPart[] {
const parts: ContentPart[] = [];
@@ -51,11 +103,15 @@
// Find all code blocks
let match;
while ((match = CODE_BLOCK_PATTERN.exec(text)) !== null) {
// Add text before this code block
// Add text before this code block (may contain tool results)
if (match.index > lastIndex) {
const textBefore = text.slice(lastIndex, match.index);
if (textBefore.trim()) {
parts.push({ type: 'text', content: textBefore });
if (containsToolResult(textBefore)) {
parts.push(...parseTextForToolResults(textBefore));
} else {
parts.push({ type: 'text', content: textBefore });
}
}
}
@@ -75,13 +131,21 @@
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex);
if (remaining.trim()) {
parts.push({ type: 'text', content: remaining });
if (containsToolResult(remaining)) {
parts.push(...parseTextForToolResults(remaining));
} else {
parts.push({ type: 'text', content: remaining });
}
}
}
// If no code blocks found, return entire content as text
// If no code blocks found, check for tool results in entire content
if (parts.length === 0 && text.trim()) {
parts.push({ type: 'text', content: text });
if (containsToolResult(text)) {
parts.push(...parseTextForToolResults(text));
} else {
parts.push({ type: 'text', content: text });
}
}
return parts;
@@ -144,8 +208,9 @@
}
}
// Parse content into parts
const contentParts = $derived(parseContent(content));
// Clean and parse content into parts
const cleanedContent = $derived(cleanCalledToolText(content));
const contentParts = $derived(parseContent(cleanedContent));
</script>
<div class="message-content">
@@ -195,6 +260,8 @@
/>
{/if}
</div>
{:else if part.type === 'tool-result'}
<ToolResultDisplay content={part.content} />
{:else}
<div class="prose prose-sm prose-invert max-w-none">
{@html renderMarkdown(part.content)}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
/**
* ToolCallDisplay - Shows tool calls made by the assistant
* Displays the tool name, arguments, and execution status
* ToolCallDisplay - Beautiful tool call visualization
* Shows tool name, formatted arguments, and status
*/
import type { ToolCall } from '$lib/types';
@@ -12,77 +12,174 @@
let { toolCalls }: Props = $props();
// Tool metadata for icons and colors
const toolMeta: Record<string, { icon: string; color: string; label: string }> = {
get_location: {
icon: '📍',
color: 'from-rose-500 to-pink-600',
label: 'Location'
},
web_search: {
icon: '🔍',
color: 'from-blue-500 to-cyan-600',
label: 'Web Search'
},
fetch_url: {
icon: '🌐',
color: 'from-violet-500 to-purple-600',
label: 'Fetch URL'
},
get_current_time: {
icon: '🕐',
color: 'from-amber-500 to-orange-600',
label: 'Time'
},
calculate: {
icon: '🧮',
color: 'from-emerald-500 to-teal-600',
label: 'Calculate'
}
};
const defaultMeta = {
icon: '⚙️',
color: 'from-slate-500 to-slate-600',
label: 'Tool'
};
/**
* Format arguments for display
* Handles both string and object arguments
* Parse arguments to display-friendly format
*/
function formatArguments(args: string): string {
function parseArgs(argsStr: string): Record<string, unknown> {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
return JSON.parse(argsStr);
} catch {
return args;
return { value: argsStr };
}
}
/**
* Check if arguments should be collapsed (too long)
* Format a single argument value for display
*/
function shouldCollapse(args: string): boolean {
return args.length > 100;
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value.length > 60 ? value.substring(0, 57) + '...' : value;
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'number') return String(value);
if (value === null || value === undefined) return '-';
return JSON.stringify(value);
}
// State for collapsed arguments
/**
* Get human-readable argument label
*/
function argLabel(key: string): string {
const labels: Record<string, string> = {
query: 'Query',
url: 'URL',
highAccuracy: 'High Accuracy',
maxResults: 'Max Results',
maxLength: 'Max Length',
extract: 'Extract',
expression: 'Expression',
precision: 'Precision',
timezone: 'Timezone',
format: 'Format'
};
return labels[key] || key;
}
// Collapsed state per tool
let expandedCalls = $state<Set<string>>(new Set());
function toggleExpand(id: string): void {
if (expandedCalls.has(id)) {
expandedCalls.delete(id);
expandedCalls = new Set(expandedCalls);
} else {
expandedCalls.add(id);
expandedCalls = new Set(expandedCalls);
}
expandedCalls = new Set(expandedCalls);
}
</script>
<div class="mt-2 space-y-2">
<div class="my-3 space-y-2">
{#each toolCalls as call (call.id)}
<div class="rounded-lg border border-slate-600 bg-slate-700/50 p-3">
<div class="flex items-center gap-2">
<!-- Tool icon -->
<div class="flex h-6 w-6 items-center justify-center rounded bg-emerald-600/20 text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
{@const meta = toolMeta[call.name] || defaultMeta}
{@const args = parseArgs(call.arguments)}
{@const argEntries = Object.entries(args).filter(([_, v]) => v !== undefined && v !== null)}
{@const isExpanded = expandedCalls.has(call.id)}
<!-- Tool name -->
<span class="font-mono text-sm font-medium text-slate-200">{call.name}</span>
<div
class="overflow-hidden rounded-xl border border-slate-700/50 bg-gradient-to-r {meta.color} p-[1px] shadow-lg"
>
<div class="rounded-xl bg-slate-900/95 backdrop-blur">
<!-- Header -->
<button
type="button"
onclick={() => toggleExpand(call.id)}
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-800/50"
>
<!-- Icon -->
<span class="text-xl" role="img" aria-label={meta.label}>{meta.icon}</span>
<!-- Expand/collapse button -->
{#if shouldCollapse(call.arguments)}
<button
type="button"
onclick={() => toggleExpand(call.id)}
class="ml-auto text-xs text-slate-400 hover:text-slate-200"
<!-- Tool name and summary -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-slate-100">{meta.label}</span>
<span class="font-mono text-xs text-slate-500">{call.name}</span>
</div>
<!-- Quick preview of main argument -->
{#if argEntries.length > 0}
{@const [firstKey, firstValue] = argEntries[0]}
<p class="mt-0.5 truncate text-sm text-slate-400">
{#if call.name === 'web_search' && typeof firstValue === 'string'}
Searching: "{firstValue}"
{:else if call.name === 'fetch_url' && typeof firstValue === 'string'}
{firstValue}
{:else if call.name === 'get_location'}
Detecting user location...
{:else if call.name === 'calculate' && typeof firstValue === 'string'}
{firstValue}
{:else}
{formatValue(firstValue)}
{/if}
</p>
{/if}
</div>
<!-- Expand indicator -->
<svg
class="h-5 w-5 flex-shrink-0 text-slate-500 transition-transform duration-200"
class:rotate-180={isExpanded}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{expandedCalls.has(call.id) ? 'Collapse' : 'Expand'}
</button>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Expanded details -->
{#if isExpanded && argEntries.length > 0}
<div class="border-t border-slate-800 px-4 py-3">
<div class="space-y-2">
{#each argEntries as [key, value]}
<div class="flex items-start gap-3 text-sm">
<span class="w-24 flex-shrink-0 font-medium text-slate-500">
{argLabel(key)}
</span>
<span class="break-all font-mono text-slate-300">
{formatValue(value)}
</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Arguments -->
{#if call.arguments && call.arguments !== '{}'}
<div class="mt-2">
{#if shouldCollapse(call.arguments) && !expandedCalls.has(call.id)}
<pre class="overflow-hidden text-ellipsis whitespace-nowrap rounded bg-slate-800 p-2 font-mono text-xs text-slate-400">{call.arguments.substring(0, 100)}...</pre>
{:else}
<pre class="overflow-x-auto rounded bg-slate-800 p-2 font-mono text-xs text-slate-400">{formatArguments(call.arguments)}</pre>
{/if}
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
/**
* ToolResultDisplay - Beautifully formatted tool execution results
* Parses JSON results and displays them in a user-friendly way
*/
interface Props {
content: string;
}
let { content }: Props = $props();
interface ParsedResult {
type: 'location' | 'search' | 'error' | 'text' | 'json';
data: unknown;
}
interface LocationData {
location?: {
city?: string;
country?: string;
latitude?: number;
longitude?: number;
};
message?: string;
source?: string;
}
interface SearchResult {
rank: number;
title: string;
url: string;
snippet: string;
}
interface SearchData {
query?: string;
resultCount?: number;
results?: SearchResult[];
}
/**
* Parse the tool result content
*/
function parseResult(text: string): ParsedResult {
// Try to extract JSON from "Tool result: {...}" format
const jsonMatch = text.match(/Tool result:\s*(\{[\s\S]*\})/);
if (!jsonMatch) {
// Check for error
if (text.includes('Tool error:')) {
const errorMatch = text.match(/Tool error:\s*(.+)/);
return { type: 'error', data: errorMatch?.[1] || text };
}
return { type: 'text', data: text };
}
try {
const data = JSON.parse(jsonMatch[1]);
// Detect result type
if (data.location && (data.location.city || data.location.latitude)) {
return { type: 'location', data };
}
if (data.results && Array.isArray(data.results) && data.query) {
return { type: 'search', data };
}
return { type: 'json', data };
} catch {
return { type: 'text', data: text };
}
}
const parsed = $derived(parseResult(content));
</script>
{#if parsed.type === 'location'}
{@const loc = parsed.data as LocationData}
<div class="my-3 overflow-hidden rounded-xl border border-rose-500/30 bg-gradient-to-r from-rose-500/10 to-pink-500/10">
<div class="flex items-center gap-3 px-4 py-3">
<span class="text-2xl">📍</span>
<div>
<p class="font-medium text-slate-100">
{#if loc.location?.city}
{loc.location.city}{#if loc.location.country}, {loc.location.country}{/if}
{:else if loc.message}
{loc.message}
{:else}
Location detected
{/if}
</p>
{#if loc.source === 'ip'}
<p class="text-xs text-slate-500">Based on IP address (approximate)</p>
{:else if loc.source === 'gps'}
<p class="text-xs text-slate-500">From device GPS</p>
{/if}
</div>
</div>
</div>
{:else if parsed.type === 'search'}
{@const search = parsed.data as SearchData}
<div class="my-3 space-y-2">
<div class="flex items-center gap-2 text-sm text-slate-400">
<span>🔍</span>
<span>Found {search.resultCount || search.results?.length || 0} results for "{search.query}"</span>
</div>
{#if search.results && search.results.length > 0}
<div class="space-y-2">
{#each search.results.slice(0, 5) as result}
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
class="block rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-colors hover:border-blue-500/50 hover:bg-slate-800"
>
<div class="flex items-start gap-2">
<span class="mt-0.5 text-blue-400">#{result.rank}</span>
<div class="min-w-0 flex-1">
<p class="font-medium text-blue-400 hover:underline">{result.title}</p>
<p class="mt-0.5 truncate text-xs text-slate-500">{result.url}</p>
{#if result.snippet && result.snippet !== '(no snippet available)'}
<p class="mt-1 text-sm text-slate-400">{result.snippet}</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
{:else if parsed.type === 'error'}
<div class="my-3 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-red-400">⚠️</span>
<span class="text-sm text-red-300">{parsed.data}</span>
</div>
</div>
{:else if parsed.type === 'json'}
{@const data = parsed.data as Record<string, unknown>}
<div class="my-3 rounded-xl border border-slate-700/50 bg-slate-800/50 p-3">
<pre class="overflow-x-auto text-xs text-slate-400">{JSON.stringify(data, null, 2)}</pre>
</div>
{:else}
<!-- Fallback: just show the text -->
<p class="text-slate-300">{parsed.data}</p>
{/if}