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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
151
frontend/src/lib/components/chat/ToolResultDisplay.svelte
Normal file
151
frontend/src/lib/components/chat/ToolResultDisplay.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user