From 9571e07687a72a272efc2fda1a9440d699c3c2fa Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH] feat: Enhance tool call streaming UI and output format --- .../chat/ChatMessages/AgenticContent.svelte | 139 ++++++++++++++---- .../webui/src/lib/stores/agentic.svelte.ts | 40 +++-- 2 files changed, 142 insertions(+), 37 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte index b6c583994d..166aa838e1 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -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 = - /\n\n\n([\s\S]*?)/g; + + // Regex for completed tool calls (with END marker) + const completedToolCallRegex = + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>/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( + /<<>>\n<<>>\n<<>>([\s\S]*)$/ + ); + + if (pendingMatch) { + // Add text before pending tool call + const pendingIndex = remainingContent.indexOf('<<>>'); + 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; + } + }
@@ -109,16 +170,24 @@
- {:else if section.type === 'tool_call'} - + {:else if section.type === 'tool_call' || section.type === 'tool_call_pending'} + {@const isPending = section.type === 'tool_call_pending'} + toggleExpanded(index)} >
- + {#if isPending} + + {:else} + + {/if} {section.toolName} + {#if isPending} + executing... + {/if}
{#if section.toolArgs && section.toolArgs !== '{}'}
-
Arguments:
-
{formatToolArgs(
-											section.toolArgs
-										)}
+
Arguments:
+
{/if} - {#if section.toolResult} -
-
Result:
-
- -
+
+
+ Result: + {#if isPending} + + {/if}
- {/if} + {#if section.toolResult} + {#if isJsonContent(section.toolResult)} + + {:else} +
+ +
+ {/if} + {:else if isPending} +
+ Waiting for result... +
+ {/if} +
diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 7ee99f44b0..560062c619 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -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`; - output += `\n`; - output += `\n`; + let output = `\n\n<<>>`; + output += `\n<<>>`; + output += `\n<<>>`; + 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\n`; + output += `\n<<>>\n`; emit(output); }