From fc13a0b73823f078749eeb968da7c839cfe5753d Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 10 Feb 2026 16:54:05 +0100 Subject: [PATCH] feat: Improve agentic turn visualization and statistics --- .../ChatMessageAgenticContent.svelte | 385 +++++++++++------- .../ChatMessages/ChatMessageAssistant.svelte | 74 +++- .../ChatMessages/ChatMessageStatistics.svelte | 48 ++- .../app/chat/ChatSettings/ChatSettings.svelte | 5 + .../src/lib/constants/settings-config.ts | 1 + .../webui/src/lib/stores/agentic.svelte.ts | 2 + 6 files changed, 332 insertions(+), 183 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index bd78ae424a..2725a28f94 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -1,5 +1,6 @@ -
- {#each sectionsParsed as section, index (index)} - {#if section.type === AgenticSectionType.TEXT} -
- -
- {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} - {@const streamingIcon = isStreaming ? Loader2 : AlertTriangle} - {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'} - {@const streamingSubtitle = isStreaming ? '' : 'incomplete'} +{#snippet renderSection(section: typeof sectionsParsed[number], index: number)} + {#if section.type === AgenticSectionType.TEXT} +
+ +
+ {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} + {@const streamingIcon = isStreaming ? Loader2 : AlertTriangle} + {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'} + {@const streamingSubtitle = isStreaming ? '' : 'incomplete'} - toggleExpanded(index, section)} - > -
-
- Arguments: - - {#if isStreaming} - - {/if} -
- {#if section.toolArgs} - - {:else if isStreaming} -
- Receiving arguments... -
- {:else} -
- Response was truncated -
- {/if} -
-
- {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const toolIcon = isPending ? Loader2 : Wrench} - {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} - - toggleExpanded(index, section)} - > - {#if section.toolArgs && section.toolArgs !== '{}'} -
-
Arguments:
- - -
- {/if} - -
-
- Result: - - {#if isPending} - - {/if} -
- {#if section.toolResult} -
- {#each section.parsedLines as line, i (i)} -
{line.text}
- {#if line.image} - {line.image.name} - {/if} - {/each} -
- {:else if isPending} -
- Waiting for result... -
- {/if} -
-
- {:else if section.type === AgenticSectionType.REASONING} - toggleExpanded(index, section)} - > -
-
- {section.content} -
-
-
- {:else if section.type === AgenticSectionType.REASONING_PENDING} - {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} - {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'} - - toggleExpanded(index, section)} - > -
-
- {section.content} -
-
-
- {/if} - {/each} - - {#if streamingToolCall} {}} + icon={streamingIcon} + iconClass={streamingIconClass} + title={section.toolName || 'Tool call'} + subtitle={streamingSubtitle} + {isStreaming} + onToggle={() => toggleExpanded(index, section)} >
Arguments: - + + {#if isStreaming} + + {/if}
- {#if streamingToolCall.arguments} + {#if section.toolArgs} - {:else} + {:else if isStreaming}
Receiving arguments...
+ {:else} +
+ Response was truncated +
{/if}
+ {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} + {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} + {@const toolIcon = isPending ? Loader2 : Wrench} + {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} + + toggleExpanded(index, section)} + > + {#if section.toolArgs && section.toolArgs !== '{}'} +
+
Arguments:
+ + +
+ {/if} + +
+
+ Result: + + {#if isPending} + + {/if} +
+ {#if section.toolResult} +
+ {#each section.parsedLines as line, i (i)} +
{line.text}
+ {#if line.image} + {line.image.name} + {/if} + {/each} +
+ {:else if isPending} +
+ Waiting for result... +
+ {/if} +
+
+ {:else if section.type === AgenticSectionType.REASONING} + toggleExpanded(index, section)} + > +
+
+ {section.content} +
+
+
+ {:else if section.type === AgenticSectionType.REASONING_PENDING} + {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} + {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'} + + toggleExpanded(index, section)} + > +
+
+ {section.content} +
+
+
+ {/if} +{/snippet} + +
+ {#if highlightTurns && turnGroups.length > 1} + {#each turnGroups as turn, turnIndex (turnIndex)} + {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]} +
+ Turn {turnIndex + 1} + {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])} + {@render renderSection(section, turn.flatIndices[sIdx])} + {/each} + {#if turnStats} +
+ 0 + ? buildTurnAgenticTimings(turnStats) + : undefined} + initialView={ChatMessageStatsView.GENERATION} + hideSummary + /> +
+ {/if} +
+ {/each} + {:else} + {#each sectionsParsed as section, index (index)} + {@render renderSection(section, index)} + {/each} {/if}
@@ -280,4 +324,31 @@ .agentic-text { width: 100%; } + + .agentic-turn { + position: relative; + border: 1.5px dashed var(--muted-foreground); + border-radius: 0.75rem; + padding: 1rem; + transition: background 0.1s; + } + + .agentic-turn-label { + position: absolute; + top: -1rem; + left: 0.75rem; + padding: 0 0.375rem; + background: var(--background); + font-size: 0.7rem; + font-weight: 500; + color: var(--muted-foreground); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .turn-stats { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid hsl(var(--muted) / 0.5); + } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index a9889946c2..369c1c1048 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -12,12 +12,13 @@ import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte'; import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte'; import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils'; + import { tick } from 'svelte'; import { fade } from 'svelte/transition'; import { Check, X } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { INPUT_CLASSES } from '$lib/constants/css-classes'; - import { MessageRole, KeyboardKey } from '$lib/enums'; + import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums'; import Label from '$lib/components/ui/label/label.svelte'; import { config } from '$lib/stores/settings.svelte'; import { isRouterMode } from '$lib/stores/server.svelte'; @@ -99,6 +100,62 @@ let currentConfig = $derived(config()); let isRouter = $derived(isRouterMode()); let showRawOutput = $state(false); + let activeStatsView = $state(ChatMessageStatsView.GENERATION); + let statsContainerEl: HTMLDivElement | undefined = $state(); + + function getScrollParent(el: HTMLElement): HTMLElement | null { + let parent = el.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if (/(auto|scroll)/.test(style.overflowY)) { + return parent; + } + parent = parent.parentElement; + } + return null; + } + + async function handleStatsViewChange(view: ChatMessageStatsView) { + const el = statsContainerEl; + if (!el) { + activeStatsView = view; + + return; + } + + const scrollParent = getScrollParent(el); + if (!scrollParent) { + activeStatsView = view; + + return; + } + + const yBefore = el.getBoundingClientRect().top; + + activeStatsView = view; + + await tick(); + + const delta = el.getBoundingClientRect().top - yBefore; + if (delta !== 0) { + scrollParent.scrollTop += delta; + } + + // Correct any drift after browser paint + requestAnimationFrame(() => { + const drift = el.getBoundingClientRect().top - yBefore; + + if (Math.abs(drift) > 1) { + scrollParent.scrollTop += drift; + } + }); + } + + let highlightAgenticTurns = $derived( + hasAgenticMarkers && + (currentConfig.alwaysShowAgenticTurns || + activeStatsView === ChatMessageStatsView.SUMMARY) + ); let displayedModel = $derived(message.model ?? null); @@ -205,6 +262,7 @@ {:else} @@ -230,7 +288,7 @@
{#if displayedModel} -
+
{#if isRouter} {:else if isLoading() && currentConfig.showMessageStats} {@const liveStats = processingState.getLiveProcessingStats()} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte index b47787fcdd..9a2a42289d 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte @@ -15,6 +15,8 @@ isProcessingPrompt?: boolean; initialView?: ChatMessageStatsView; agenticTimings?: ChatMessageAgenticTimings; + onActiveViewChange?: (view: ChatMessageStatsView) => void; + hideSummary?: boolean; } let { @@ -25,12 +27,18 @@ isLive = false, isProcessingPrompt = false, initialView = ChatMessageStatsView.GENERATION, - agenticTimings + agenticTimings, + onActiveViewChange, + hideSummary = false }: Props = $props(); let activeView: ChatMessageStatsView = $state(initialView); let hasAutoSwitchedToGeneration = $state(false); + $effect(() => { + onActiveViewChange?.(activeView); + }); + // In live mode: auto-switch to GENERATION tab when prompt processing completes $effect(() => { if (isLive) { @@ -177,26 +185,28 @@ - - - - + Summary + + - -

Agentic summary

-
-
+ +

Agentic summary

+
+ + {/if} {/if}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte index 538070f4e0..6c8ef1516e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte @@ -255,6 +255,11 @@ label: 'Agentic loop max turns', type: 'input' }, + { + key: 'alwaysShowAgenticTurns', + label: 'Always show agentic turns in conversation', + type: 'checkbox' + }, { key: 'agenticMaxToolPreviewLines', label: 'Max lines per tool preview', diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 569d9d1b49..a7e412d3cd 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -27,6 +27,7 @@ export const SETTING_CONFIG_DEFAULT: Record = agenticMaxTurns: 10, agenticMaxToolPreviewLines: 25, showToolCallInProgress: false, + alwaysShowAgenticTurns: false, // make sure these default values are in sync with `common.h` samplers: 'top_k;typ_p;top_p;min_p;temperature', backend_sampling: false, diff --git a/tools/server/webui/src/lib/stores/agentic.svelte.ts b/tools/server/webui/src/lib/stores/agentic.svelte.ts index 3da57f7fd9..66376b2ba4 100644 --- a/tools/server/webui/src/lib/stores/agentic.svelte.ts +++ b/tools/server/webui/src/lib/stores/agentic.svelte.ts @@ -437,6 +437,8 @@ class AgenticStore { } if (turnToolCalls.length === 0) { + agenticTimings.perTurn!.push(turnStats); + onComplete?.( '', undefined,