feat: Enhance tool call streaming UI and output format
This commit is contained in:
parent
260375819d
commit
9571e07687
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue