From b7288a4dd72be24d7f43cef8c069c98104261bee Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 10 Jan 2026 18:48:46 +0100 Subject: [PATCH] webui: enable streaming of tool call arguments --- .../webui/src/lib/clients/openai-sse.ts | 3 + .../chat/ChatMessages/AgenticContent.svelte | 37 ++--------- .../server/webui/src/lib/constants/agentic.ts | 9 +-- .../webui/src/lib/stores/agentic.svelte.ts | 62 ++++++++++++------- 4 files changed, 50 insertions(+), 61 deletions(-) diff --git a/tools/server/webui/src/lib/clients/openai-sse.ts b/tools/server/webui/src/lib/clients/openai-sse.ts index 2de9533d1d..57f33931d1 100644 --- a/tools/server/webui/src/lib/clients/openai-sse.ts +++ b/tools/server/webui/src/lib/clients/openai-sse.ts @@ -166,6 +166,9 @@ export class OpenAISseClient { } processToolCalls(delta?.tool_calls); + if (aggregatedToolCalls.length > 0) { + callbacks.onToolCallChunk?.(JSON.stringify(aggregatedToolCalls)); + } } } 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 f1f8b2362d..46e02de460 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 @@ -73,13 +73,7 @@ } const toolName = match[1]; - const toolArgsBase64 = match[2]; - let toolArgs = ''; - try { - toolArgs = decodeURIComponent(escape(atob(toolArgsBase64))); - } catch { - toolArgs = toolArgsBase64; - } + const toolArgs = match[2]; // Direct JSON const toolResult = match[3].replace(/^\n+|\n+$/g, ''); sections.push({ @@ -111,14 +105,8 @@ } 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 toolArgs = pendingMatch[2]; // Direct JSON + // Capture streaming result content (everything after TOOL_ARGS_END marker) const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, ''); sections.push({ @@ -137,24 +125,7 @@ } } - const partialArgsBase64 = partialWithNameMatch[2] || ''; - let partialArgs = ''; - if (partialArgsBase64) { - try { - // Try to decode - may fail if incomplete base64 - partialArgs = decodeURIComponent(escape(atob(partialArgsBase64))); - } catch { - // If decoding fails, try padding the base64 - try { - const padded = - partialArgsBase64 + '=='.slice(0, (4 - (partialArgsBase64.length % 4)) % 4); - partialArgs = decodeURIComponent(escape(atob(padded))); - } catch { - // Show raw base64 if all decoding fails - partialArgs = ''; - } - } - } + const partialArgs = partialWithNameMatch[2] || ''; // Direct JSON streaming sections.push({ type: AgenticSectionType.TOOL_CALL_STREAMING, diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts index ea06bab48b..6c6cc665ca 100644 --- a/tools/server/webui/src/lib/constants/agentic.ts +++ b/tools/server/webui/src/lib/constants/agentic.ts @@ -12,7 +12,8 @@ export const AGENTIC_TAGS = { TOOL_CALL_START: '<<>>', TOOL_CALL_END: '<<>>', TOOL_NAME_PREFIX: '<<>>', + TOOL_ARGS_END: '<<>>', TAG_SUFFIX: '>>>' } as const; @@ -20,13 +21,13 @@ export const AGENTIC_TAGS = { export const AGENTIC_REGEX = { // Matches completed tool calls (with END marker) COMPLETED_TOOL_CALL: - /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>/g, + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*?)<<>>/g, // Matches pending tool call (has NAME and ARGS but no END) PENDING_TOOL_CALL: - /<<>>\n<<>>\n<<>>([\s\S]*)$/, + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>([\s\S]*)$/, // Matches partial tool call (has START and NAME, ARGS still streaming) PARTIAL_WITH_NAME: - /<<>>\n<<>>\n<<>>\n<<>>\n<<>>([\s\S]*)$/, // Matches early tool call (just START marker) EARLY_MATCH: /<<>>([\s\S]*)$/, // Matches partial marker at end of content diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 8180933cb9..4fdb69ef4f 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -239,6 +239,42 @@ class AgenticStore { // Prepare session state const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); const allToolCalls: ApiChatCompletionToolCall[] = []; + + // Wrapper to emit agentic tags progressively during streaming + const emittedToolCallStates = $state( + new Map() + ); + const wrappedOnToolCallChunk = (serializedToolCalls: string) => { + const toolCalls: ApiChatCompletionToolCall[] = JSON.parse(serializedToolCalls); + + for (let i = 0; i < toolCalls.length; i++) { + const toolCall = toolCalls[i]; + const toolName = toolCall.function?.name ?? ''; + const toolArgs = toolCall.function?.arguments ?? ''; + + const state = emittedToolCallStates.get(i) || { emittedOnce: false, lastArgs: '' }; + + if (!state.emittedOnce) { + // First emission: send full header + args + let output = `\n\n<<>>`; + output += `\n<<>>`; + output += `\n<<>>\n`; + output += toolArgs; + onChunk?.(output); + state.emittedOnce = true; + state.lastArgs = toolArgs; + } else if (toolArgs !== state.lastArgs) { + // Subsequent emissions: send only delta + const delta = toolArgs.slice(state.lastArgs.length); + onChunk?.(delta); + state.lastArgs = toolArgs; + } + + emittedToolCallStates.set(i, state); + } + + onToolCallChunk?.(serializedToolCalls); + }; let capturedTimings: ChatMessageTimings | undefined; // Build base request from options (messages change per turn) @@ -278,6 +314,7 @@ class AgenticStore { { onChunk, onReasoningChunk: shouldFilterReasoning ? undefined : onReasoningChunk, + onToolCallChunk: wrappedOnToolCallChunk, onModel, onFirstValidChunk: undefined, onProcessingUpdate: (timings, progress) => { @@ -323,7 +360,6 @@ class AgenticStore { }); } this._totalToolCalls = allToolCalls.length; - onToolCallChunk?.(JSON.stringify(allToolCalls)); // Add assistant message with tool calls to session sessionMessages.push({ @@ -339,9 +375,6 @@ class AgenticStore { return; } - // Emit tool call start (shows "pending" state in UI) - this.emitToolCallStart(toolCall, onChunk); - const mcpCall: MCPToolCall = { id: toolCall.id, function: { @@ -404,26 +437,6 @@ class AgenticStore { })); } - /** - * Emit tool call start marker (shows "pending" state in UI). - */ - private emitToolCallStart( - toolCall: AgenticToolCallList[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<<>>`; - emit(output); - } - /** * Emit tool call result and end marker. */ @@ -435,6 +448,7 @@ class AgenticStore { if (!emit) return; let output = ''; + output += `\n<<>>`; if (this.isBase64Image(result)) { output += `\n![tool-result](${result.trim()})`; } else {