ui: Context usage gauge and panel (#25340)

* feat: WIP

* feat: Retire ChatScreenProcessingInfo component, context, and keepStatsVisible settings

* feat: Always-on gauge with active-model /props, conversation stats and live-reactive reading/output/avg

* feat: Add /tokenize endpoint, TokenizeService, FNV-1a and JSON Schema utilities

* feat: Surface enabled-tools token count in context hover card

* refactor(tools): make toolsStore the sole owner of the OpenAI wire format

Previously mcpStore.getToolDefinitionsForLLM() owned the MCP->OpenAI
shape conversion (plus normalizeSchemaProperties). That created two
sources of truth for what gets sent to the LLM, with the
duplication-prone risk of the deduplicated enabled list (which feeds
the token-count cache) drifting from the bytes actually shipped on
chat.

Now:
- mcpStore: pure protocol state + routing. Drop getToolDefinitionsForLLM
  and the inline OpenAIToolDefinition conversion + normalizeSchemaProperties.
  Doc comment adjusted to declare wire-format ownership as belonging
  to toolsStore. Connection lifecycle, health checks, executeTool,
  and the connections/toolsIndex remain.
- toolsStore: owns the wire shape (added earlier this series). mcpEntries()
  inlines the MCP tool conversion; uses normalizeJsonSchema (the JSON
  Schema util extracted in the prior commit) so missing 'type' fields
  are inferred from defaults. mcpTools getter iterates mcpEntries() so
  the Settings UI and the deduplicated enabled list see the same
  definitions. getEnabledToolsForLLM iterates mcpEntries() instead of
  calling mcpStore, so the JSON sent to the LLM is identical to what
  toolsStore.refreshEnabledToolsTokenCount tokenizes.
- agentic: the chat-completion tools field's type was annotated as
  ReturnType<typeof mcpStore.getToolDefinitionsForLLM>, claiming the
  shape was owned by mcpStore. Switch to ReturnType<typeof
  toolsStore.getEnabledToolsForLLM>, the actual source.

Assisted-by: Claude

* feat: UI WIP

* feat: UI WIP

* feat: UI WIP

* feat: Adjust reasoning submenu layout and spacing

* feat: Adjust context usage gauge thresholds and styling

* feat: Split context usage gauge stats into current and cumulative breakdowns

* chore: Format

* refactor: Cleanup

* refactor: Cleanup

* feat: improve token gauge accuracy and display

* refactor: remove MCP recommendation gating and simplify server visibility

* feat: add token audit logging to ChatStore for debugging

* refactor: Simplify context token reading to use server promptTokens directly

* feat: Replace last-known token tracking with live server-derived stats for accurate streaming gauges

* feat: UI Improvements

* feat: Move prompt processing stats to the preceding user message

* feat: Fix context token double-counting and refine gauge layout

* refactor: remove always-show-agentic-turns setting and simplify agentic turn display

* feat: track and display cache tokens in context gauge

* feat: add diagnostic logging for chat completion requests

* refactor: improve token audit console output with fresh/cached breakdown

* fix: invalidate enabled tools token count cache on tool changes

* test: add unit tests for tools store token count invalidation

* refactor: Remove tools token counting infrastructure

* refactor: Update ChatFormContextGauge to use simplified token tracking

* refactor: Update ChatStore to remove tools token counting

* chore: Formatting

* feat: Improve UI text

* feat: simplify context usage derivation and refine gauge labels

* refactor: cleanup logs

* cleaning

* fix: UI

* refactor: Enums

* refactor: Extract context gauge logic into hook and split UI into sub-components

* refactor: Cleanup comments

---------

Co-authored-by: Pascal <admin@serveurperso.com>
This commit is contained in:
Aleksander Grygier
2026-07-08 09:22:35 +02:00
committed by GitHub
co-authored by Pascal
parent da46e59cbf
commit f1161b15f2
53 changed files with 1234 additions and 659 deletions
@@ -24,6 +24,8 @@
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean;
isLastUserMessage?: boolean;
nextAssistantMessage?: DatabaseMessage | null;
siblingInfo?: ChatMessageSiblingInfo | null;
}
@@ -32,6 +34,8 @@
message,
toolMessages = [],
isLastAssistantMessage = false,
isLastUserMessage = false,
nextAssistantMessage = null,
siblingInfo = null
}: Props = $props();
@@ -359,7 +363,9 @@
<ChatMessageUser
class={className}
{deletionInfo}
{isLastUserMessage}
{message}
{nextAssistantMessage}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
@@ -11,11 +11,10 @@
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
import { AgenticSectionType } from '$lib/enums';
import { AgenticSectionType, ChatMessageStatisticsMode } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { tick } from 'svelte';
import { fade } from 'svelte/transition';
import { MessageRole, ChatMessageStatsView } from '$lib/enums';
import { MessageRole } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
@@ -122,62 +121,6 @@
return parts.join('\n\n\n');
});
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(
isAgentic &&
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
);
let displayedModel = $derived(message.model ?? null);
// model being switched to while it loads, so the selector bar tracks it
@@ -291,7 +234,6 @@
{toolMessages}
isStreaming={isChatStreaming()}
{isLastAssistantMessage}
highlightTurns={highlightAgenticTurns}
/>
{/if}
{:else}
@@ -315,10 +257,7 @@
<div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel}
<div
bind:this={statsContainerEl}
class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
>
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
{#if isRouter}
<ModelsSelectorDropdown
currentModel={pendingModel ?? displayedModel}
@@ -347,28 +286,25 @@
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
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()}
{@const genStats = processingState.getLiveGenerationStats()}
{@const promptProgress = processingState.processingState?.promptProgress}
{@const isStillProcessingPrompt =
promptProgress && promptProgress.processed < promptProgress.total}
{#if liveStats || genStats}
{#if genStats}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
isLive
isProcessingPrompt={!!isStillProcessingPrompt}
promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs}
predictedTokens={genStats?.tokensGenerated}
predictedMs={genStats?.timeMs}
predictedTokens={genStats.tokensGenerated}
predictedMs={genStats.timeMs}
/>
{/if}
{/if}
@@ -2,10 +2,14 @@
import {
ChatMessageActionIcons,
ChatMessageEditForm,
ChatMessageStatistics,
ChatMessageUserBubble
} from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading } from '$lib/stores/chat.svelte';
import { MessageRole, ChatMessageStatisticsMode } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
interface Props {
class?: string;
@@ -17,6 +21,8 @@
assistantMessages: number;
messageTypes: string[];
} | null;
isLastUserMessage?: boolean;
nextAssistantMessage?: DatabaseMessage | null;
showDeleteDialog: boolean;
onEdit: () => void;
onDelete: () => void;
@@ -32,6 +38,8 @@
message,
siblingInfo = null,
deletionInfo,
isLastUserMessage = false,
nextAssistantMessage = null,
showDeleteDialog,
onEdit,
onDelete,
@@ -44,6 +52,37 @@
// Get contexts
const editCtx = getMessageEditContext();
const processingState = useProcessingState();
const currentConfig = $derived(config());
const isActivelyProcessing = $derived(isLastUserMessage && isLoading());
// For agentic turns, prefer the cumulative agentic.llm totals over per-call timings.
let storedReadingStats = $derived.by(() => {
const timings = nextAssistantMessage?.timings;
if (!timings?.prompt_n || !timings?.prompt_ms) return null;
const agentic = timings.agentic;
return {
promptTokens: agentic ? agentic.llm.prompt_n : timings.prompt_n,
promptMs: agentic ? agentic.llm.prompt_ms : timings.prompt_ms
};
});
let showStoredReadingStats = $derived(
Boolean(currentConfig.showMessageStats) && storedReadingStats !== null
);
let showLiveReadingStats = $derived(
Boolean(currentConfig.showMessageStats) && isActivelyProcessing && storedReadingStats === null
);
$effect(() => {
if (showLiveReadingStats) {
processingState.startMonitoring();
}
});
</script>
<div
@@ -60,6 +99,37 @@
renderMarkdown={true}
/>
{#if showStoredReadingStats}
<!-- Reading stats sourced from the assistant message that followed this turn -->
<div class="info my-2 grid w-full justify-items-end gap-4 tabular-nums">
<div
class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
promptTokens={storedReadingStats!.promptTokens}
promptMs={storedReadingStats!.promptMs}
/>
</div>
</div>
{:else if showLiveReadingStats}
{@const liveStats = processingState.getLiveProcessingStats()}
{#if liveStats}
<div class="info my-2 grid w-full justify-items-end gap-4 tabular-nums">
<div
class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
isLive
promptTokens={liveStats.tokensProcessed}
promptMs={liveStats.timeMs}
/>
</div>
</div>
{/if}
{/if}
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActionIcons
@@ -2,7 +2,6 @@
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { getProcessingInfoContext } from '$lib/contexts';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
interface Props {
@@ -23,9 +22,6 @@
onDelete
}: Props = $props();
const processingInfoCtx = getProcessingInfoContext();
let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
const editCtx = useMessageEditContext({
getContent: () => content,
getExtras: () => extras,
@@ -36,9 +32,7 @@
<div
use:fadeInView
aria-label="Pending user message"
class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky {showProcessingInfo
? 'bottom-44'
: 'bottom-32'}"
class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky bottom-32"
role="group"
>
{#if editCtx.isEditing}
@@ -41,15 +41,13 @@
toolMessages?: DatabaseMessage[];
isStreaming?: boolean;
isLastAssistantMessage?: boolean;
highlightTurns?: boolean;
}
let {
message,
toolMessages = [],
isStreaming = false,
isLastAssistantMessage = false,
highlightTurns = false
isLastAssistantMessage = false
}: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
@@ -57,6 +55,7 @@
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const showMessageStats = $derived(config().showMessageStats as boolean);
const hasReasoningError = $derived(
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
@@ -354,16 +353,17 @@
{/snippet}
<div class="agentic-content">
{#if highlightTurns && turnGroups.length > 1}
{#if turnGroups.length > 1}
{#each turnGroups as turn, turnIndex (turnIndex)}
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
<div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
<div class="agentic-turn group/turn grid gap-3 mb-4">
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
{@render renderSection(section, turn.flatIndices[sIdx])}
{/each}
{#if turnStats}
<div class="turn-stats">
{#if turnStats && showMessageStats}
<div class="turn-stats transition-opacity duration-150">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
@@ -402,39 +402,21 @@
.agentic-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 48rem;
gap: 1rem;
}
.agentic-content > :global(*),
.agentic-turn > :global(*) {
min-width: 0;
}
.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>
@@ -2,7 +2,7 @@
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
import { ChatMessageStatisticsBadge } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import { ChatMessageStatsView, ChatMessageStatisticsMode } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
@@ -19,6 +19,7 @@
agenticTimings?: ChatMessageAgenticTimings;
onActiveViewChange?: (view: ChatMessageStatsView) => void;
hideSummary?: boolean;
mode?: ChatMessageStatisticsMode;
}
let {
@@ -31,19 +32,30 @@
initialView = ChatMessageStatsView.GENERATION,
agenticTimings,
onActiveViewChange,
hideSummary = false
hideSummary = false,
mode = ChatMessageStatisticsMode.SWITCHABLE
}: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView);
let isSwitchable = $derived(mode === ChatMessageStatisticsMode.SWITCHABLE);
let activeView: ChatMessageStatsView = $derived(
mode === ChatMessageStatisticsMode.READING
? ChatMessageStatsView.READING
: mode === ChatMessageStatisticsMode.GENERATION
? ChatMessageStatsView.GENERATION
: initialView
);
let hasAutoSwitchedToGeneration = $state(false);
$effect(() => {
onActiveViewChange?.(activeView);
if (isSwitchable) {
onActiveViewChange?.(activeView);
}
});
// In live mode: auto-switch to GENERATION tab when prompt processing completes
$effect(() => {
if (isLive) {
if (isLive && isSwitchable) {
// Auto-switch to generation tab only when prompt processing is done (once)
if (
!hasAutoSwitchedToGeneration &&
@@ -91,8 +103,7 @@
formattedPromptTime !== undefined
);
// In live mode, generation tab is disabled until we have generation stats
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
let isGenerationDisabled = $derived(isLive && isSwitchable && !hasGenerationStats);
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
@@ -153,44 +164,44 @@
{/snippet}
<div class="inline-flex items-center text-xs text-muted-foreground">
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive}
{@render viewButton({
view: ChatMessageStatsView.READING,
icon: BookOpenText,
label: 'Reading',
tooltipText: 'Reading (prompt processing)'
})}
{/if}
{@render viewButton({
view: ChatMessageStatsView.GENERATION,
icon: Sparkles,
label: 'Generation',
tooltipText: isGenerationDisabled
? 'Generation (waiting for tokens...)'
: 'Generation (token output)',
disabled: isGenerationDisabled
})}
{#if hasAgenticStats}
{@render viewButton({
view: ChatMessageStatsView.TOOLS,
icon: Wrench,
label: 'Tools',
tooltipText: 'Tool calls'
})}
{#if !hideSummary}
{#if isSwitchable}
<div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5">
{#if hasPromptStats || isLive}
{@render viewButton({
view: ChatMessageStatsView.SUMMARY,
icon: Layers,
label: 'Summary',
tooltipText: 'Agentic summary'
view: ChatMessageStatsView.READING,
icon: BookOpenText,
label: 'Reading',
tooltipText: 'Processing'
})}
{/if}
{/if}
</div>
{@render viewButton({
view: ChatMessageStatsView.GENERATION,
icon: Sparkles,
label: 'Generation',
tooltipText: isGenerationDisabled ? 'Waiting for tokens...' : 'Generation',
disabled: isGenerationDisabled
})}
{#if hasAgenticStats}
{@render viewButton({
view: ChatMessageStatsView.TOOLS,
icon: Wrench,
label: 'Tools',
tooltipText: 'Tool calls'
})}
{#if !hideSummary}
{@render viewButton({
view: ChatMessageStatsView.SUMMARY,
icon: Layers,
label: 'Summary',
tooltipText: 'Agentic summary'
})}
{/if}
{/if}
</div>
{/if}
<div class="flex items-center gap-1 px-2">
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
@@ -256,7 +267,7 @@
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
/>
{:else if hasPromptStats}
{:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
@@ -186,6 +186,8 @@
message: DatabaseMessage;
toolMessages: DatabaseMessage[];
isLastAssistantMessage: boolean;
isLastUserMessage: boolean;
nextAssistantMessage: DatabaseMessage | null;
siblingInfo: ChatMessageSiblingInfo;
}> = [];
@@ -236,18 +238,36 @@
message: msg,
toolMessages,
isLastAssistantMessage: false,
isLastUserMessage: false,
nextAssistantMessage: null,
siblingInfo
});
}
// Mark the last assistant message
let lastAssistantIdx = -1;
for (let i = result.length - 1; i >= 0; i--) {
if (result[i].message.role === MessageRole.ASSISTANT) {
result[i].isLastAssistantMessage = true;
lastAssistantIdx = i;
break;
}
}
if (lastAssistantIdx > 0 && result[lastAssistantIdx - 1].message.role === MessageRole.USER) {
result[lastAssistantIdx - 1].isLastUserMessage = true;
}
for (let i = 0; i < result.length; i++) {
if (result[i].message.role !== MessageRole.USER) continue;
for (let j = i + 1; j < result.length; j++) {
if (result[j].message.role === MessageRole.ASSISTANT) {
result[i].nextAssistantMessage = result[j].message;
break;
}
}
}
return result;
});
</script>
@@ -257,12 +277,14 @@
{isVisible ? 'opacity-100' : 'opacity-0'}
{previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
{message}
{toolMessages}
{isLastAssistantMessage}
{isLastUserMessage}
{nextAssistantMessage}
{siblingInfo}
/>
{/each}