feat: Enhance tool call streaming UI and output format

This commit is contained in:
Aleksander Grygier 2026-01-02 19:37:41 +01:00
parent 260375819d
commit 9571e07687
2 changed files with 142 additions and 37 deletions

View File

@ -7,8 +7,8 @@
* similar to the reasoning/thinking block UI.
*/
import { MarkdownContent } from '$lib/components/app';
import { Wrench } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { Wrench, Loader2 } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
@ -19,7 +19,7 @@
}
interface AgenticSection {
type: 'text' | 'tool_call';
type: 'text' | 'tool_call' | 'tool_call_pending';
content: string;
toolName?: string;
toolArgs?: string;
@ -46,13 +46,16 @@
if (!rawContent) return [];
const sections: AgenticSection[] = [];
const toolCallRegex =
/<!-- AGENTIC_TOOL_CALL_START -->\n<!-- TOOL_NAME: (.+?) -->\n<!-- TOOL_ARGS: (.+?) -->\n([\s\S]*?)<!-- AGENTIC_TOOL_CALL_END -->/g;
// Regex for completed tool calls (with END marker)
const completedToolCallRegex =
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g;
let lastIndex = 0;
let match;
while ((match = toolCallRegex.exec(rawContent)) !== null) {
// First pass: find all completed tool calls
while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
// Add text before this tool call
if (match.index > lastIndex) {
const textBefore = rawContent.slice(lastIndex, match.index).trim();
@ -61,9 +64,15 @@
}
}
// Add tool call section
// Add completed tool call section
const toolName = match[1];
const toolArgs = match[2].replace(/\\n/g, '\n');
const toolArgsBase64 = match[2];
let toolArgs = '';
try {
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
} catch {
toolArgs = toolArgsBase64;
}
const toolResult = match[3].trim();
sections.push({
@ -77,8 +86,43 @@
lastIndex = match.index + match[0].length;
}
// Add remaining text after last tool call
if (lastIndex < rawContent.length) {
// Check for pending tool call at the end (START without END)
const remainingContent = rawContent.slice(lastIndex);
const pendingMatch = remainingContent.match(
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\s\S]*)$/
);
if (pendingMatch) {
// Add text before pending tool call
const pendingIndex = remainingContent.indexOf('<<<AGENTIC_TOOL_CALL_START>>>');
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: 'text', content: textBefore });
}
}
// Add pending tool call
const toolName = pendingMatch[1];
const toolArgsBase64 = pendingMatch[2];
let toolArgs = '';
try {
toolArgs = decodeURIComponent(escape(atob(toolArgsBase64)));
} catch {
toolArgs = toolArgsBase64;
}
// Capture streaming result content (everything after args marker)
const streamingResult = pendingMatch[3]?.trim() || '';
sections.push({
type: 'tool_call_pending',
content: streamingResult,
toolName,
toolArgs,
toolResult: streamingResult || undefined
});
} else if (lastIndex < rawContent.length) {
// Add remaining text after last completed tool call
const remainingText = rawContent.slice(lastIndex).trim();
if (remainingText) {
sections.push({ type: 'text', content: remainingText });
@ -101,6 +145,23 @@
return args;
}
}
function isJsonContent(content: string): boolean {
const trimmed = content.trim();
return (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
);
}
function formatJsonContent(content: string): string {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch {
return content;
}
}
</script>
<div class="agentic-content">
@ -109,16 +170,24 @@
<div class="agentic-text">
<MarkdownContent content={section.content} />
</div>
{:else if section.type === 'tool_call'}
<Collapsible.Root open={isExpanded(index)} class="mb-4">
{:else if section.type === 'tool_call' || section.type === 'tool_call_pending'}
{@const isPending = section.type === 'tool_call_pending'}
<Collapsible.Root open={isExpanded(index)} class="my-4">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger
class="flex w-full cursor-pointer items-center justify-between p-3"
onclick={() => toggleExpanded(index)}
>
<div class="flex items-center gap-2 text-muted-foreground">
<Wrench class="h-4 w-4" />
{#if isPending}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Wrench class="h-4 w-4" />
{/if}
<span class="font-mono text-sm font-medium">{section.toolName}</span>
{#if isPending}
<span class="text-xs italic">executing...</span>
{/if}
</div>
<div
@ -137,22 +206,42 @@
<div class="border-t border-muted px-3 pb-3">
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="mb-1 text-xs text-muted-foreground">Arguments:</div>
<pre
class="rounded bg-muted/30 p-2 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">{formatToolArgs(
section.toolArgs
)}</pre>
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
{#if section.toolResult}
<div class="pt-3">
<div class="mb-1 text-xs text-muted-foreground">Result:</div>
<div class="text-sm">
<MarkdownContent content={section.toolResult} />
</div>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{/if}
{#if section.toolResult}
{#if isJsonContent(section.toolResult)}
<SyntaxHighlightedCode
code={formatJsonContent(section.toolResult)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
{:else}
<div class="text-sm">
<MarkdownContent content={section.toolResult} disableMath />
</div>
{/if}
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
</div>
</Collapsible.Content>
</Card>

View File

@ -330,6 +330,9 @@ class AgenticStore {
return;
}
// Emit tool call start (shows "pending" state in UI)
this.emitToolCallStart(toolCall, onChunk);
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: {
@ -355,8 +358,8 @@ class AgenticStore {
return;
}
// Emit tool preview (raw output for UI to format later)
this.emitToolPreview(toolCall, result, maxToolPreviewLines, onChunk);
// Emit tool result and end marker
this.emitToolCallResult(result, maxToolPreviewLines, onChunk);
// Add tool result to session (sanitize base64 images for context)
const contextValue = this.isBase64Image(result) ? '[Image displayed to user]' : result;
@ -393,33 +396,46 @@ class AgenticStore {
}
/**
* Emit tool call preview to the chunk callback.
* Output is raw/sterile - UI formatting is a separate concern.
* Emit tool call start marker (shows "pending" state in UI).
*/
private emitToolPreview(
private emitToolCallStart(
toolCall: AgenticToolCallList[number],
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
const toolName = toolCall.function.name;
const toolArgs = toolCall.function.arguments;
// Base64 encode args to avoid conflicts with markdown/HTML parsing
const toolArgsBase64 = btoa(unescape(encodeURIComponent(toolArgs)));
let output = `\n\n<!-- AGENTIC_TOOL_CALL_START -->`;
output += `\n<!-- TOOL_NAME: ${toolName} -->`;
output += `\n<!-- TOOL_ARGS: ${toolArgs.replace(/\n/g, '\\n')} -->`;
let output = `\n\n<<<AGENTIC_TOOL_CALL_START>>>`;
output += `\n<<<TOOL_NAME:${toolName}>>>`;
output += `\n<<<TOOL_ARGS_BASE64:${toolArgsBase64}>>>`;
emit(output);
}
/**
* Emit tool call result and end marker.
*/
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) return;
let output = '';
if (this.isBase64Image(result)) {
output += `\n![tool-result](${result.trim()})`;
} else {
// Don't wrap in code fences - result may already be markdown with its own code blocks
const lines = result.split('\n');
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `\n\`\`\`\n${trimmedLines.join('\n')}\n\`\`\``;
output += `\n${trimmedLines.join('\n')}`;
}
output += `\n<!-- AGENTIC_TOOL_CALL_END -->\n`;
output += `\n<<<AGENTIC_TOOL_CALL_END>>>\n`;
emit(output);
}