feat: Improve agentic turn visualization and statistics

This commit is contained in:
Aleksander Grygier 2026-02-10 16:54:05 +01:00
parent 3a355675dc
commit fc13a0b738
6 changed files with 332 additions and 183 deletions

View File

@ -1,5 +1,6 @@
<script lang="ts">
import {
ChatMessageStatistics,
CollapsibleContentBlock,
MarkdownContent,
SyntaxHighlightedCode
@ -11,14 +12,14 @@
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants/agentic';
import { parseAgenticContent, type AgenticSection } from '$lib/utils/agentic';
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums';
interface Props {
/** Optional database message for context */
message?: DatabaseMessage;
/** Raw content string to parse and display */
content: string;
/** Whether content is currently streaming */
isStreaming?: boolean;
highlightTurns?: boolean;
}
type ToolResultLine = {
@ -26,7 +27,7 @@
image?: DatabaseMessageExtraImageFile;
};
let { content, message, isStreaming = false }: Props = $props();
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
@ -44,6 +45,39 @@
}))
);
// Group flat sections into agentic turns
// A new turn starts when a non-tool section follows a tool section
const turnGroups = $derived.by(() => {
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
let currentTurn: (typeof sectionsParsed)[number][] = [];
let currentIndices: number[] = [];
let prevWasTool = false;
for (let i = 0; i < sectionsParsed.length; i++) {
const section = sectionsParsed[i];
const isTool =
section.type === AgenticSectionType.TOOL_CALL ||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
if (!isTool && prevWasTool && currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
currentTurn = [];
currentIndices = [];
}
currentTurn.push(section);
currentIndices.push(i);
prevWasTool = isTool;
}
if (currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
}
return turns;
});
function getDefaultExpanded(section: AgenticSection): boolean {
if (
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
@ -92,179 +126,189 @@
return { text: line, image };
});
}
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return {
turns: 1,
toolCallsCount: stats.toolCalls.length,
toolsMs: stats.toolsMs,
toolCalls: stats.toolCalls,
llm: stats.llm
};
}
</script>
<div class="agentic-content">
{#each sectionsParsed as section, index (index)}
{#if section.type === AgenticSectionType.TEXT}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
</div>
{: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}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
</div>
{: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'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={streamingIcon}
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
{#if isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
{:else if isStreaming}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{:else}
<div
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
>
Response was truncated
</div>
{/if}
</div>
</CollapsibleContentBlock>
{: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'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
isStreaming={isPending}
onToggle={() => toggleExpanded(index, section)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<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 section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
{#each section.parsedLines as line, i (i)}
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.image}
<img
src={line.image.base64Url}
alt={line.image.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{/each}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title="Reasoning"
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title={reasoningTitle}
subtitle={reasoningSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{/if}
{/each}
{#if streamingToolCall}
<CollapsibleContentBlock
open={true}
open={isExpanded(index, section)}
class="my-2"
icon={Loader2}
iconClass="h-4 w-4 animate-spin"
title={streamingToolCall.name || 'Tool call'}
subtitle="streaming..."
onToggle={() => {}}
icon={streamingIcon}
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
<Loader2 class="h-3 w-3 animate-spin" />
{#if isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if streamingToolCall.arguments}
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(streamingToolCall.arguments)}
language="json"
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
{:else}
{:else if isStreaming}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{:else}
<div
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
>
Response was truncated
</div>
{/if}
</div>
</CollapsibleContentBlock>
{: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'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
isStreaming={isPending}
onToggle={() => toggleExpanded(index, section)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<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 section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
{#each section.parsedLines as line, i (i)}
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.image}
<img
src={line.image.base64Url}
alt={line.image.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{/each}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title="Reasoning"
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title={reasoningTitle}
subtitle={reasoningSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{/if}
{/snippet}
<div class="agentic-content">
{#if highlightTurns && turnGroups.length > 1}
{#each turnGroups as turn, turnIndex (turnIndex)}
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
<div class="agentic-turn hover:bg-muted/80 dark:hover:bg-muted/30 my-2">
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
{@render renderSection(section, turn.flatIndices[sIdx])}
{/each}
{#if turnStats}
<div class="turn-stats">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
predictedTokens={turnStats.llm.predicted_n}
predictedMs={turnStats.llm.predicted_ms}
agenticTimings={turnStats.toolCalls.length > 0
? buildTurnAgenticTimings(turnStats)
: undefined}
initialView={ChatMessageStatsView.GENERATION}
hideSummary
/>
</div>
{/if}
</div>
{/each}
{:else}
{#each sectionsParsed as section, index (index)}
{@render renderSection(section, index)}
{/each}
{/if}
</div>
@ -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);
}
</style>

View File

@ -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>(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 @@
<ChatMessageAgenticContent
content={messageContent || ''}
isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns}
{message}
/>
{:else}
@ -230,7 +288,7 @@
<div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel}
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<div bind:this={statsContainerEl} class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
{#if isRouter}
<ModelsSelector
currentModel={displayedModel}
@ -251,12 +309,14 @@
{/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
promptTokens={message.timings.prompt_n}
promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms}
agenticTimings={message.timings.agentic}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
agenticTimings={agentic}
onActiveViewChange={handleStatsViewChange}
/>
{:else if isLoading() && currentConfig.showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}

View File

@ -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 @@
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.SUMMARY
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
>
<Layers class="h-3 w-3" />
{#if !hideSummary}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.SUMMARY
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
>
<Layers class="h-3 w-3" />
<span class="sr-only">Summary</span>
</button>
</Tooltip.Trigger>
<span class="sr-only">Summary</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agentic summary</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Content>
<p>Agentic summary</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/if}
</div>

View File

@ -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',

View File

@ -27,6 +27,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
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,

View File

@ -437,6 +437,8 @@ class AgenticStore {
}
if (turnToolCalls.length === 0) {
agenticTimings.perTurn!.push(turnStats);
onComplete?.(
'',
undefined,