diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index 9dce3a0c9d..7216de6823 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -11,7 +11,7 @@ "@chromatic-com/storybook": "5.0.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", - "@internationalized/date": "3.10.1", + "@internationalized/date": "3.12.2", "@lucide/svelte": "0.515.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", @@ -2981,9 +2981,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", - "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/tools/ui/package.json b/tools/ui/package.json index bcb4165d10..8b3516a02c 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -30,7 +30,7 @@ "@chromatic-com/storybook": "5.0.0", "@eslint/compat": "1.4.1", "@eslint/js": "9.39.2", - "@internationalized/date": "3.10.1", + "@internationalized/date": "3.12.2", "@lucide/svelte": "0.515.0", "@modelcontextprotocol/sdk": "1.26.0", "@playwright/test": "1.56.1", diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte index 4795403211..433a3662df 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte @@ -11,7 +11,8 @@ } from '$lib/constants'; import { ChatFormActionAddToolsSubmenu, - ChatFormActionAddMcpServersSubmenu + ChatFormActionAddMcpServersSubmenu, + ChatFormActionAddReasoningSubmenu } from '$lib/components/app'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; @@ -92,7 +93,11 @@ - + + + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte similarity index 63% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte index f6bcbcb09e..070fd3ac66 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte @@ -2,7 +2,7 @@ import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { ReasoningEffort, MessageRole } from '$lib/enums'; + import { ReasoningEffort } from '$lib/enums'; import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens'; import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort'; import type { ReasoningEffortLevel } from '$lib/types'; @@ -18,31 +18,23 @@ import { isRouterMode } from '$lib/stores/server.svelte'; import type { DatabaseMessage } from '$lib/types/database'; - let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); - let currentEffort = $derived(conversationsStore.getReasoningEffort()); - let isOff = $derived(!thinkingEnabled); - let tooltipText = $derived(thinkingEnabled ? `${currentEffort} Reasoning` : 'Disabled Reasoning'); let subOpen = $state(false); - // Get conversation model from message history let conversationModel = $derived( chatStore.getConversationModel(activeMessages() as DatabaseMessage[]) ); - // Fallback: if model props aren't available, check if any assistant messages - // for this model in the active conversation have reasoning content. let modelSupportsThinkingFromMessages = $derived.by(() => { const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null; if (!modelId) return false; + const messages = conversationsStore.activeMessages; + return messages.some( - (m: DatabaseMessage) => - m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent + (m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent ); }); - // Check if model supports thinking. Primary: chat template from /props. - // Fallback: message history (reasoning content in assistant messages). let modelSupportsThinking = $derived.by(() => { loadedModelIds(); propsCacheVersion(); @@ -52,15 +44,15 @@ return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages; } - // In non-router mode, use the built-in supportsThinking return supportsThinking() || modelSupportsThinkingFromMessages; }); - // Check if current item is selected + let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); + let currentEffort = $derived(conversationsStore.getReasoningEffort()); + let isOff = $derived(!thinkingEnabled); + function isSelected(item: ReasoningEffortLevel): boolean { - if (item.isOff) { - return isOff; - } + if (item.isOff) return isOff; return thinkingEnabled && currentEffort === item.value; } @@ -76,39 +68,30 @@ {#if modelSupportsThinking} - - - - - {#if thinkingEnabled} - - {:else} - - {/if} - - + + + {#if thinkingEnabled} + + {:else} + + {/if} - -

{tooltipText}

-
-
+ + Reasoning - + {thinkingEnabled ? currentEffort : 'off'} + + +
+ + -
Reasoning effort
- {#each REASONING_EFFORT_LEVELS as level (level.value)} {/each} -
- + + {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte index a80f00bc67..7be356f2a8 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte @@ -7,14 +7,20 @@ ChatFormActionModels, ChatFormActionRecord, ChatFormActionSubmit, - ChatFormReasoningToggle + ChatFormContextGauge } from '$lib/components/app'; - import { FileTypeCategory } from '$lib/enums'; + import { FileTypeCategory, MessageRole } from '$lib/enums'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { config } from '$lib/stores/settings.svelte'; - import { conversationsStore } from '$lib/stores/conversations.svelte'; + import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte'; + import { + activeProcessingState, + isChatStreaming, + isLoading as chatIsLoading + } from '$lib/stores/chat.svelte'; import { getFileTypeCategory } from '$lib/utils'; import { goto } from '$app/navigation'; + import { page } from '$app/state'; import { ROUTES } from '$lib/constants/routes'; interface Props { @@ -93,6 +99,36 @@ let activeMessage = $derived( conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1] ); + + let hasProcessedTokens = $derived.by(() => { + if (!page.params.id) return false; + + const messages = activeMessages() as DatabaseMessage[]; + let totalHistoricalTokens = 0; + for (const m of messages) { + if (m.role !== MessageRole.ASSISTANT) continue; + const timings = m.timings; + if (!timings) continue; + const agenticLlm = timings.agentic?.llm; + if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) { + totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0); + } else { + totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0); + } + } + if (totalHistoricalTokens > 0) return true; + + if (!chatIsLoading() && !isChatStreaming()) return false; + + const processingState = activeProcessingState(); + if (!processingState) return false; + const livePromptTokens = Math.max( + processingState.promptTokens ?? 0, + processingState.promptProgress?.processed ?? 0 + ); + const liveOutputTokens = processingState.outputTokensUsed ?? 0; + return livePromptTokens > 0 || liveOutputTokens > 0; + });
{#if showAddButton} -
+
{/if} -
- +
+ {#if hasProcessedTokens} + + {/if} {#if showModelSelector} - + {#if thinkingEnabled} {:else} @@ -89,23 +88,15 @@ {/if} - + {#each REASONING_EFFORT_LEVELS as level (level.value)} {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte new file mode 100644 index 0000000000..855cf6ce78 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte @@ -0,0 +1,108 @@ + + + + + + + + +
+
+ Context + · + + {formatParameters(gauge.contextUsed)} + / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'} + +
+ + {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded} + + {:else if showProgressBar} +
+
+
+ +
+ + {gauge.contextPercent}% used + + + {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining + +
+ {:else} +
No context info available
+ {/if} + + {#if gauge.hasAnyUsage} + + {/if} +
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte new file mode 100644 index 0000000000..71c6a33cb1 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetailRow.svelte @@ -0,0 +1,20 @@ + + +
+
+ {label} + {value} +
+ + {#if subtitle} +
{subtitle}
+ {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte new file mode 100644 index 0000000000..fdec5aca5a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte @@ -0,0 +1,122 @@ + + + + + Token usage details + + + + + + {#if hasCumulative} +
+

+ Across all turns +

+ +
+ {#if cumulativeRead > 0} + 0 + ? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache` + : undefined} + /> + {/if} + {#if cumulativeOutput > 0} + + {/if} +
+
+ {/if} + + {#if hasCurrent} +
+

+ This turn · KV cache +

+ +
+ {#if currentRead > 0} + 0 + ? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached` + : undefined} + /> + {/if} + + {#if currentOutput > 0} + + {/if} + +
+
+ KV cache total + {kvTotal.toLocaleString()} tok +
+
+
+
+ {/if} + + {#if averageTokensPerSecond !== null} +
+ +
+ {/if} + + {#each transientDetails as detail (detail)} +
{detail}
+ {/each} +
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte new file mode 100644 index 0000000000..6e2616d363 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDial.svelte @@ -0,0 +1,43 @@ + + + + + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte new file mode 100644 index 0000000000..24a67cfdfd --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeLoadModel.svelte @@ -0,0 +1,24 @@ + + +{#if modelId !== null && !isLoading} +
+ Available context size is only visible once the model is loaded. + +
+{:else if isLoading} +
+ + Loading model... +
+{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts new file mode 100644 index 0000000000..5b00100156 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge.ts @@ -0,0 +1,37 @@ +export type ColorLevel = 'ok' | 'warning' | 'critical' | 'neutral'; + +const WARNING_THRESHOLD = 80; +const CRITICAL_THRESHOLD = 95; + +export function colorLevelFromPercent(percent: number | null): ColorLevel { + if (percent === null) return 'neutral'; + if (percent >= CRITICAL_THRESHOLD) return 'critical'; + if (percent >= WARNING_THRESHOLD) return 'warning'; + return 'ok'; +} + +export function colorLevelTextClass(level: ColorLevel): string { + switch (level) { + case 'critical': + return 'text-red-400'; + case 'warning': + return 'text-amber-400'; + case 'ok': + return 'text-muted-foreground'; + default: + return 'text-muted-foreground'; + } +} + +export function colorLevelBgClass(level: ColorLevel): string { + switch (level) { + case 'critical': + return 'bg-red-500'; + case 'warning': + return 'bg-amber-500'; + case 'ok': + return 'bg-green-500'; + default: + return 'bg-muted'; + } +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index dadcae0c49..2b4a3f9d39 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -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 @@ (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 @@
{#if displayedModel} -
+
{#if isRouter} {: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} {/if} {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte index 80a0183e6b..f7590c3a31 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte @@ -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(); + } + });
+ {#if showStoredReadingStats} + +
+
+ +
+
+ {:else if showLiveReadingStats} + {@const liveStats = processingState.getLiveProcessingStats()} + {#if liveStats} +
+
+ +
+
+ {/if} + {/if} + {#if message.timestamp}
content, getExtras: () => extras, @@ -36,9 +32,7 @@
{#if editCtx.isEditing} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index 07b0489cc3..f0d03f5478 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -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 = $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}
- {#if highlightTurns && turnGroups.length > 1} + {#if 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} -
+ + {#if turnStats && showMessageStats} +
: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); } diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte index 6906adbb17..7ef73a4994 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte @@ -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}
-
- {#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} +
+ {#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} -
+ + {@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} +
+ {/if}
{#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)} = []; @@ -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; }); @@ -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)} {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index d2fba5a932..a0d66c6421 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -4,12 +4,10 @@ ChatScreenForm, ChatMessages, ChatScreenDragOverlay, - ChatScreenProcessingInfo, ChatScreenStreamResumeStatus, ServerLoadingSplash, ChatScreenServerError } from '$lib/components/app'; - import { setProcessingInfoContext } from '$lib/contexts'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte'; import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte'; @@ -23,8 +21,7 @@ errorDialog, isLoading, isChatStreaming, - isEditing, - activeProcessingState + isEditing } from '$lib/stores/chat.svelte'; import { conversationsStore, @@ -42,12 +39,6 @@ let { showCenteredEmpty = false } = $props(); - setProcessingInfoContext({ - get showProcessingInfo() { - return showProcessingInfo; - } - }); - let disableAutoScroll = $derived(Boolean(config().disableAutoScroll) || isMobile.current); let isMobileUserScrolledUp = $state(false); let mobileScrollDownHint = $state(false); @@ -63,11 +54,6 @@ let isServerLoading = $derived(serverLoading()); let hasPropsError = $derived(!!serverError()); let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming()); - let showProcessingInfo = $derived( - isCurrentConversationLoading || - (config().keepStatsVisible && !!page.params.id) || - activeProcessingState() !== null - ); let chatFormBottomPosition = $derived.by(() => { if (!isMobile.current) return '1rem'; if (device.isStandalone) return '1.5rem'; @@ -298,10 +284,6 @@ }} /> {/if} - - {#if showProcessingInfo} - - {/if}
- import { untrack } from 'svelte'; - import { PROCESSING_INFO_TIMEOUT } from '$lib/constants'; - import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; - import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte'; - import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte'; - import { config } from '$lib/stores/settings.svelte'; - - const processingState = useProcessingState(); - - let isCurrentConversationLoading = $derived(isLoading()); - let isStreaming = $derived(isChatStreaming()); - let processingDetails = $derived(processingState.getTechnicalDetails()); - - let processingVisible = $derived(processingDetails.length > 0); - - let { onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void } = $props(); - - $effect(() => { - onVisibilityChange?.(processingVisible); - }); - - $effect(() => { - const conversation = activeConversation(); - - untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null)); - }); - - $effect(() => { - const keepStatsVisible = config().keepStatsVisible; - const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming; - - if (shouldMonitor) { - processingState.startMonitoring(); - } - - if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) { - const timeout = setTimeout(() => { - if (!config().keepStatsVisible && !isChatStreaming()) { - processingState.stopMonitoring(); - } - }, PROCESSING_INFO_TIMEOUT); - - return () => clearTimeout(timeout); - } - }); - - $effect(() => { - const conversation = activeConversation(); - const messages = activeMessages() as DatabaseMessage[]; - const keepStatsVisible = config().keepStatsVisible; - - if (keepStatsVisible && conversation) { - if (messages.length === 0) { - untrack(() => chatStore.clearProcessingState(conversation.id)); - return; - } - - if (!isCurrentConversationLoading && !isStreaming) { - untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id)); - } - } - }); - - - - - diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index d7004d3ae3..4f826841e4 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -241,13 +241,18 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte'; /** - * **ChatFormReasoningToggle** - Thinking toggle button with effort dropdown + * Dropdown submenu for selecting reasoning effort level. * - * A toggle button with lightbulb icon that indicates thinking status. - * Shows the reasoning effort dropdown when clicked. + * Shows a "Reasoning" sub-menu item with a lightbulb icon indicating + * thinking status, and a nested list of effort levels. * Only visible when the current model supports thinking. */ -export { default as ChatFormReasoningToggle } from './ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte'; +export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte'; + +/** + * Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip. + */ +export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge/ChatFormContextGauge.svelte'; /** * Hidden file input element for programmatic file selection. @@ -669,14 +674,6 @@ export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOve */ export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte'; -/** - * Processing info display during generation. Shows real-time statistics: - * tokens per second, prompt/completion token counts, and elapsed time. - * Data sourced from slotsService polling during active generation. - * Only visible when `isCurrentConversationLoading` is true. - */ -export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProcessingInfo.svelte'; - /** * Server error alert displayed when the server is unreachable. * Shows the error message with a retry button. diff --git a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte index 8bab55d19f..3875b449a1 100644 --- a/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte +++ b/tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte @@ -76,7 +76,7 @@ open = value; onToggle?.(); }} - class={className} + class="{className} my-0!" > diff --git a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte index c4d1706bfe..d8dfe0ad9c 100644 --- a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte +++ b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte @@ -72,8 +72,8 @@
{@html highlightedHtml}
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte index 9b4489b828..cdbc055eef 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpServerRecommendations.svelte @@ -4,9 +4,10 @@ import * as Dialog from '$lib/components/ui/dialog'; import { fly } from 'svelte/transition'; import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp'; - import { RECOMMENDED_MCP_SERVERS } from '$lib/constants'; + import { RECOMMENDED_MCP_SERVERS, SETTINGS_KEYS } from '$lib/constants'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; + import { settingsStore } from '$lib/stores/settings.svelte'; import { uuid } from '$lib/utils'; import { MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, MCP_SERVER_ID_PREFIX } from '$lib/constants'; import type { MCPServerSettingsEntry } from '$lib/types'; @@ -24,6 +25,22 @@ ); let addedServers = $state([]); + let didAddAny = $state(false); + + let selectedRecommendedCount = $derived.by( + () => RECOMMENDED_MCP_SERVERS.filter((server) => selected[server.id]).length + ); + + let footerLabel = $derived.by(() => { + const recommended = selectedRecommendedCount; + const custom = addedServers.length; + const total = recommended + custom; + + if (total === 0) return 'Continue'; + if (recommended === 0) return custom === 1 ? 'Add server' : `Add ${custom} servers`; + if (custom === 0) return recommended === 1 ? 'Add server' : `Add ${recommended} servers`; + return `Add ${recommended} servers and ${custom} custom`; + }); let showAddForm = $state(false); let newServerUrl = $state(''); @@ -44,9 +61,14 @@ showAddForm = false; newServerUrl = ''; newServerHeaders = ''; - addedServers = []; + + if (!didAddAny) { + settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, []); + } localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true'); + addedServers = []; + didAddAny = false; } open = value; onOpenChange?.(value); @@ -59,6 +81,7 @@ } function enableSelected() { + didAddAny = true; localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true'); for (const server of RECOMMENDED_MCP_SERVERS) { @@ -83,6 +106,8 @@ function saveNewServer() { if (newServerUrlError) return; + didAddAny = true; + const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`; localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true'); @@ -174,7 +199,12 @@ - + diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte index b568324965..8bb49a0958 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte @@ -39,13 +39,17 @@ {@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null} - + {#if group.source === 'mcp'} + + {:else} + + {/if} diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte new file mode 100644 index 0000000000..db1406a7b5 --- /dev/null +++ b/tools/ui/src/lib/components/ui/hover-card/hover-card-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte new file mode 100644 index 0000000000..9dc25827de --- /dev/null +++ b/tools/ui/src/lib/components/ui/hover-card/hover-card-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte new file mode 100644 index 0000000000..2d42f89c39 --- /dev/null +++ b/tools/ui/src/lib/components/ui/hover-card/hover-card-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/ui/src/lib/components/ui/hover-card/hover-card.svelte b/tools/ui/src/lib/components/ui/hover-card/hover-card.svelte new file mode 100644 index 0000000000..ffc075dcd5 --- /dev/null +++ b/tools/ui/src/lib/components/ui/hover-card/hover-card.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/ui/src/lib/components/ui/hover-card/index.ts b/tools/ui/src/lib/components/ui/hover-card/index.ts new file mode 100644 index 0000000000..098f69176d --- /dev/null +++ b/tools/ui/src/lib/components/ui/hover-card/index.ts @@ -0,0 +1,15 @@ +import Root from './hover-card.svelte'; +import Content from './hover-card-content.svelte'; +import Trigger from './hover-card-trigger.svelte'; +import Portal from './hover-card-portal.svelte'; + +export { + Root, + Content, + Trigger, + Portal, + Root as HoverCard, + Content as HoverCardContent, + Trigger as HoverCardTrigger, + Portal as HoverCardPortal +}; diff --git a/tools/ui/src/lib/constants/context-keys.ts b/tools/ui/src/lib/constants/context-keys.ts index 12de0d0bcb..0bd733b370 100644 --- a/tools/ui/src/lib/constants/context-keys.ts +++ b/tools/ui/src/lib/constants/context-keys.ts @@ -1,4 +1,3 @@ export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit'; export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions'; export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config'; -export const CONTEXT_KEY_PROCESSING_INFO = 'processing-info'; diff --git a/tools/ui/src/lib/constants/css-classes.ts b/tools/ui/src/lib/constants/css-classes.ts index ca5386fcdf..009b5c52ee 100644 --- a/tools/ui/src/lib/constants/css-classes.ts +++ b/tools/ui/src/lib/constants/css-classes.ts @@ -17,3 +17,4 @@ export const PANEL_CLASSES = ` `; export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80'; +export const DIALOG_SUBMENU_CONTENT = 'w-60'; diff --git a/tools/ui/src/lib/constants/reasoning-effort.ts b/tools/ui/src/lib/constants/reasoning-effort.ts index d854e912a8..28a24420e6 100644 --- a/tools/ui/src/lib/constants/reasoning-effort.ts +++ b/tools/ui/src/lib/constants/reasoning-effort.ts @@ -6,6 +6,7 @@ import type { ReasoningEffortLevel } from '$lib/types'; * Keys match the ReasoningEffort enum values for type-safe lookups. */ export const REASONING_EFFORT_LABELS: Record = { + [ReasoningEffort.OFF]: 'Off', [ReasoningEffort.LOW]: 'Low', [ReasoningEffort.MEDIUM]: 'Medium', [ReasoningEffort.HIGH]: 'High', @@ -13,7 +14,7 @@ export const REASONING_EFFORT_LABELS: Record = { }; export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [ - { value: 'off', label: 'Off', isOff: true }, + { value: ReasoningEffort.OFF, label: 'Off', isOff: true }, { value: ReasoningEffort.LOW, label: 'Low' }, { value: ReasoningEffort.MEDIUM, label: 'Medium' }, { value: ReasoningEffort.HIGH, label: 'High' }, diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.ts index d4d7827051..ea8963044d 100644 --- a/tools/ui/src/lib/constants/settings-keys.ts +++ b/tools/ui/src/lib/constants/settings-keys.ts @@ -22,7 +22,6 @@ export const SETTINGS_KEYS = { // Display SHOW_MESSAGE_STATS: 'showMessageStats', SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress', - KEEP_STATS_VISIBLE: 'keepStatsVisible', AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty', RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown', DISABLE_AUTO_SCROLL: 'disableAutoScroll', @@ -61,7 +60,6 @@ export const SETTINGS_KEYS = { MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds', MCP_DEFAULT_SERVER_OVERRIDES: 'mcpDefaultServerOverrides', AGENTIC_MAX_TURNS: 'agenticMaxTurns', - ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns', AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines', SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress', // Performance diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.ts index b314510ab0..f8657d673d 100644 --- a/tools/ui/src/lib/constants/settings-registry.ts +++ b/tools/ui/src/lib/constants/settings-registry.ts @@ -258,18 +258,6 @@ const SETTINGS_REGISTRY: Record = { paramType: SyncableParameterType.BOOLEAN } }, - { - key: SETTINGS_KEYS.KEEP_STATS_VISIBLE, - label: 'Keep stats visible after generation', - help: 'Keep processing statistics visible after generation finishes.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE, - paramType: SyncableParameterType.BOOLEAN - } - }, { key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY, label: 'Show microphone on empty input', @@ -379,18 +367,6 @@ const SETTINGS_REGISTRY: Record = { paramType: SyncableParameterType.BOOLEAN } }, - { - key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, - label: 'Always show agentic turns in conversation', - help: 'Always expand and display agentic loop turns in conversation messages.', - defaultValue: false, - type: SettingsFieldType.CHECKBOX, - section: SETTINGS_SECTION_SLUGS.DISPLAY, - sync: { - serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS, - paramType: SyncableParameterType.BOOLEAN - } - }, { key: SETTINGS_KEYS.SHOW_BUILD_VERSION, label: 'Show build version information', diff --git a/tools/ui/src/lib/constants/storage.ts b/tools/ui/src/lib/constants/storage.ts index d0c5f2effe..eca9739bae 100644 --- a/tools/ui/src/lib/constants/storage.ts +++ b/tools/ui/src/lib/constants/storage.ts @@ -21,7 +21,6 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool /** Disabled tools keyed by stable selection identity, no migration from the name based key */ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`; export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`; -export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`; export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`; /** Set when user has interacted with the MCP server recommendations dialog (checked servers, added custom server, or dismissed) */ export const MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpServersSetupDone`; diff --git a/tools/ui/src/lib/contexts/index.ts b/tools/ui/src/lib/contexts/index.ts index 01cd1d4b70..c6719fa9e4 100644 --- a/tools/ui/src/lib/contexts/index.ts +++ b/tools/ui/src/lib/contexts/index.ts @@ -17,9 +17,3 @@ export { setChatSettingsConfigContext, type ChatSettingsConfigContext } from './chat-settings-config.context'; - -export { - getProcessingInfoContext, - setProcessingInfoContext, - type ProcessingInfoContext -} from './processing-info.context'; diff --git a/tools/ui/src/lib/contexts/processing-info.context.ts b/tools/ui/src/lib/contexts/processing-info.context.ts deleted file mode 100644 index 0cf43336f1..0000000000 --- a/tools/ui/src/lib/contexts/processing-info.context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getContext, setContext } from 'svelte'; -import { CONTEXT_KEY_PROCESSING_INFO } from '$lib/constants'; - -export interface ProcessingInfoContext { - readonly showProcessingInfo: boolean; -} - -const PROCESSING_INFO_KEY = Symbol.for(CONTEXT_KEY_PROCESSING_INFO); - -export function setProcessingInfoContext(ctx: ProcessingInfoContext): ProcessingInfoContext { - return setContext(PROCESSING_INFO_KEY, ctx); -} - -export function getProcessingInfoContext(): ProcessingInfoContext { - return getContext(PROCESSING_INFO_KEY); -} diff --git a/tools/ui/src/lib/enums/chat.enums.ts b/tools/ui/src/lib/enums/chat.enums.ts index 278e4af84e..f4994bb8e9 100644 --- a/tools/ui/src/lib/enums/chat.enums.ts +++ b/tools/ui/src/lib/enums/chat.enums.ts @@ -5,6 +5,12 @@ export enum ChatMessageStatsView { SUMMARY = 'summary' } +export enum ChatMessageStatisticsMode { + SWITCHABLE = 'switchable', + READING = 'reading', + GENERATION = 'generation' +} + /** * Connection state of a streamed completion, drives the resume status indicator. */ diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts index d8e47958bb..847014e78c 100644 --- a/tools/ui/src/lib/enums/index.ts +++ b/tools/ui/src/lib/enums/index.ts @@ -10,6 +10,7 @@ export { AgenticSectionType, ContinueIntentKind, ToolCallType } from './agentic. export { ChatMessageStatsView, + ChatMessageStatisticsMode, StreamConnectionState, ContentPartType, ConversationSelectionMode, diff --git a/tools/ui/src/lib/enums/reasoning-effort.enums.ts b/tools/ui/src/lib/enums/reasoning-effort.enums.ts index dadb0c7264..172f118e7f 100644 --- a/tools/ui/src/lib/enums/reasoning-effort.enums.ts +++ b/tools/ui/src/lib/enums/reasoning-effort.enums.ts @@ -3,6 +3,7 @@ * These values are sent to the server and mapped to token budgets. */ export enum ReasoningEffort { + OFF = 'off', LOW = 'low', MEDIUM = 'medium', HIGH = 'high', diff --git a/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts b/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts new file mode 100644 index 0000000000..e11e2f7ab1 --- /dev/null +++ b/tools/ui/src/lib/hooks/use-context-gauge.svelte.ts @@ -0,0 +1,295 @@ +/** + * Reactive state for the context usage gauge: resolves the active model, + * fetches its cached props, parses live server stats, and exposes per-turn + * read / fresh / cache / output and cumulative token counts. + */ + +import { + modelsStore, + modelOptions, + selectedModelId, + singleModelName +} from '$lib/stores/models.svelte'; +import { chatStore } from '$lib/stores/chat.svelte'; +import { activeMessages } from '$lib/stores/conversations.svelte'; +import { isRouterMode } from '$lib/stores/server.svelte'; +import { MessageRole } from '$lib/enums'; +import { STATS_UNITS } from '$lib/constants'; +import type { ChatMessageTimings, DatabaseMessage } from '$lib/types'; +import { useProcessingState } from './use-processing-state.svelte'; +import { + colorLevelFromPercent, + type ColorLevel +} from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/context-gauge'; + +interface LiveStats { + freshTokens: number; + promptTokens: number; + cacheTokens: number; + outputTokens: number; +} + +export interface UseContextGaugeReturn { + readonly activeModelId: string | null; + readonly isActiveModelLoaded: boolean; + readonly isActiveModelLoading: boolean; + readonly contextTotal: number | null; + readonly contextUsed: number; + readonly currentRead: number; + readonly currentFresh: number; + readonly currentCache: number; + readonly currentOutput: number; + readonly kvTotal: number; + readonly cumulativeRead: number; + readonly cumulativeOutput: number; + readonly cumulativeCacheTotal: number; + readonly averageTokensPerSecond: number | null; + readonly contextPercent: number | null; + readonly colorLevel: ColorLevel; + readonly transientDetails: string[]; + readonly hasAnyUsage: boolean; + loadModel(): Promise; + startMonitoring(): void; +} + +function lastAssistantTimings(messages: DatabaseMessage[]): ChatMessageTimings | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === MessageRole.ASSISTANT && m.timings) return m.timings; + } + return undefined; +} + +function deriveLiveStats( + state: ReturnType['processingState'] +): LiveStats | null { + if (!state || (state.status !== 'preparing' && state.status !== 'generating')) { + return null; + } + const promptTokens = state.promptTokens ?? 0; + const cacheTokens = state.cacheTokens ?? 0; + return { + freshTokens: promptTokens, + promptTokens: promptTokens + cacheTokens, + cacheTokens, + outputTokens: state.outputTokensUsed ?? 0 + }; +} + +const TRANSIENT_DETAILS_EXCLUDED_PREFIXES = ['Context:', 'Output:']; + +function filterTransientDetails(raw: string[]): string[] { + return raw.filter((detail) => { + if (TRANSIENT_DETAILS_EXCLUDED_PREFIXES.some((prefix) => detail.startsWith(prefix))) { + return false; + } + return !detail.includes(STATS_UNITS.TOKENS_PER_SECOND); + }); +} + +export function useContextGauge(): UseContextGaugeReturn { + const processingState = useProcessingState(); + + // Resolve the model the gauge reports context for: explicit selection > + // last assistant model > single-model mode (mirrors useChatScreenActiveModel). + const activeModelId = $derived.by(() => { + if (!isRouterMode()) { + return singleModelName(); + } + + const selectedId = selectedModelId(); + if (selectedId) { + const model = modelOptions().find((m) => m.id === selectedId); + if (model) return model.model; + } + + return chatStore.getConversationModel(activeMessages() as DatabaseMessage[]); + }); + + const isActiveModelLoaded = $derived( + activeModelId !== null && modelsStore.isModelLoaded(activeModelId) + ); + + const isActiveModelLoading = $derived( + activeModelId !== null && modelsStore.isModelOperationInProgress(activeModelId) + ); + + // Pull /props on demand so n_ctx surfaces before the first chat request. + $effect(() => { + if (activeModelId && isActiveModelLoaded) { + const cached = modelsStore.getModelProps(activeModelId); + if (!cached) { + void modelsStore.fetchModelProps(activeModelId); + } + } + }); + + const contextTotal = $derived.by(() => { + void modelsStore.propsCacheVersion; + return activeModelId ? modelsStore.getModelContextSize(activeModelId) : null; + }); + + const liveStats = $derived(deriveLiveStats(processingState.processingState)); + + const currentRead = $derived.by(() => { + const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]); + let read = 0; + if (timings) { + read = (timings.prompt_n ?? 0) + (timings.cache_n ?? 0); + } + // live.promptTokens is already the combined reading (prompt + cache), + // so do not also add live.cacheTokens. + if (liveStats && liveStats.promptTokens > 0) { + read = Math.max(read, liveStats.promptTokens); + } + return read; + }); + + const currentFresh = $derived.by(() => { + const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]); + const fresh = timings?.prompt_n ?? 0; + return Math.max(fresh, liveStats?.freshTokens ?? 0); + }); + + const currentCache = $derived.by(() => { + const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]); + const cached = timings?.cache_n ?? 0; + if (liveStats && liveStats.promptTokens > 0) { + return Math.max(cached, liveStats.cacheTokens); + } + return cached; + }); + + const currentOutput = $derived.by(() => { + if (liveStats && liveStats.outputTokens > 0) return liveStats.outputTokens; + const timings = lastAssistantTimings(activeMessages() as DatabaseMessage[]); + return timings?.predicted_n ?? 0; + }); + + const kvTotal = $derived(currentRead + currentOutput); + const contextUsed = $derived(currentRead + currentOutput); + + const cumulative = $derived.by(() => { + const messages = activeMessages() as DatabaseMessage[]; + + // Agentic sessions stamp the same agentic.llm totals onto every + // assistant message; cache_n is never per-turn so cache_total stays 0. + const agenticMessages = messages.filter( + (m) => m.role === MessageRole.ASSISTANT && m.timings?.agentic?.llm?.predicted_n != null + ); + + if (agenticMessages.length > 0) { + const llm = agenticMessages[agenticMessages.length - 1].timings!.agentic!.llm; + const output = llm.predicted_n ?? 0; + const outputMs = llm.predicted_ms ?? 0; + const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null; + return { + read: llm.prompt_n ?? 0, + output, + cacheTotal: 0, + averageTokensPerSecond + }; + } + + let read = 0; + let output = 0; + let outputMs = 0; + let cacheTotal = 0; + for (const m of messages) { + if (m.role !== MessageRole.ASSISTANT || !m.timings) continue; + read += m.timings.prompt_n ?? 0; + cacheTotal += m.timings.cache_n ?? 0; + output += m.timings.predicted_n ?? 0; + outputMs += m.timings.predicted_ms ?? 0; + } + const averageTokensPerSecond = outputMs > 0 && output > 0 ? (output / outputMs) * 1000 : null; + return { read, output, cacheTotal, averageTokensPerSecond }; + }); + + const contextPercent = $derived.by(() => { + if (contextTotal === null || contextTotal <= 0) return null; + return Math.round((contextUsed / contextTotal) * 100); + }); + + const colorLevel = $derived(colorLevelFromPercent(contextPercent)); + + // Drop lines the surrounding Context / Output / speed rows already render. + const transientDetails = $derived(filterTransientDetails(processingState.getTechnicalDetails())); + + const hasAnyUsage = $derived( + cumulative.read > 0 || + cumulative.output > 0 || + currentRead > 0 || + currentOutput > 0 || + cumulative.averageTokensPerSecond !== null || + transientDetails.length > 0 + ); + + async function loadModel() { + if (!activeModelId || isActiveModelLoading) return; + try { + await modelsStore.loadModel(activeModelId); + } catch { + // toast already surfaced by modelsStore.loadModel + } + } + + return { + get activeModelId() { + return activeModelId; + }, + get isActiveModelLoaded() { + return isActiveModelLoaded; + }, + get isActiveModelLoading() { + return isActiveModelLoading; + }, + get contextTotal() { + return contextTotal; + }, + get contextUsed() { + return contextUsed; + }, + get currentRead() { + return currentRead; + }, + get currentFresh() { + return currentFresh; + }, + get currentCache() { + return currentCache; + }, + get currentOutput() { + return currentOutput; + }, + get kvTotal() { + return kvTotal; + }, + get cumulativeRead() { + return cumulative.read; + }, + get cumulativeOutput() { + return cumulative.output; + }, + get cumulativeCacheTotal() { + return cumulative.cacheTotal; + }, + get averageTokensPerSecond() { + return cumulative.averageTokensPerSecond; + }, + get contextPercent() { + return contextPercent; + }, + get colorLevel() { + return colorLevel; + }, + get transientDetails() { + return transientDetails; + }, + get hasAnyUsage() { + return hasAnyUsage; + }, + loadModel, + startMonitoring: () => processingState.startMonitoring() + }; +} diff --git a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts b/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts index c8a85fa8f7..4f4c2c7825 100644 --- a/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts +++ b/tools/ui/src/lib/hooks/use-mcp-recommendations.svelte.ts @@ -54,11 +54,6 @@ export function useMcpRecommendations() { // effect, and we must not wipe the timeout that was just scheduled. if (checked) return; - if (mcpStore.optedInRecommendationIds.size > 0) { - checked = true; - return; - } - const hasRecommendations = mcpStore .getServers() .some((server) => RECOMMENDED_MCP_SERVER_IDS.has(server.id)); diff --git a/tools/ui/src/lib/hooks/use-processing-state.svelte.ts b/tools/ui/src/lib/hooks/use-processing-state.svelte.ts index f280319723..9fbda75d67 100644 --- a/tools/ui/src/lib/hooks/use-processing-state.svelte.ts +++ b/tools/ui/src/lib/hooks/use-processing-state.svelte.ts @@ -1,5 +1,4 @@ import { activeProcessingState } from '$lib/stores/chat.svelte'; -import { config } from '$lib/stores/settings.svelte'; import { STATS_UNITS } from '$lib/constants'; import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } from '$lib/types'; @@ -46,7 +45,6 @@ export function useProcessingState(): UseProcessingStateReturn { return activeProcessingState(); }); - // Track last known state for keepStatsVisible functionality $effect(() => { if (processingState && isMonitoring) { lastKnownState = processingState; @@ -88,14 +86,8 @@ export function useProcessingState(): UseProcessingStateReturn { function stopMonitoring(): void { if (!isMonitoring) return; - isMonitoring = false; - // Only clear last known state if keepStatsVisible is disabled - const currentConfig = config(); - if (!currentConfig.keepStatsVisible) { - lastKnownState = null; - lastKnownProcessingStats = null; - } + isMonitoring = false; } function getProcessingMessage(): string { diff --git a/tools/ui/src/lib/services/chat.service.ts b/tools/ui/src/lib/services/chat.service.ts index dbd0a94f10..15f4cc3b5f 100644 --- a/tools/ui/src/lib/services/chat.service.ts +++ b/tools/ui/src/lib/services/chat.service.ts @@ -340,6 +340,7 @@ export class ChatService { if (stream && conversationId) { headers['X-Conversation-Id'] = streamIdentity(conversationId, options.model); } + const response = await fetch(API_CHAT.COMPLETIONS, { method: 'POST', headers, @@ -1011,7 +1012,7 @@ export class ChatService { * * @param response - The fetch Response object containing the JSON data * @param onComplete - Optional callback invoked when response is successfully parsed - * @param onError - Optional callback invoked if an error occurs during parsing + * @param onError - Optional callback invoked if an error occurs while parsing * @returns {Promise} Promise that resolves to the generated content string * @throws {Error} if the response cannot be parsed or is malformed */ diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts index d7709bc6bd..981283be93 100644 --- a/tools/ui/src/lib/services/migration.service.ts +++ b/tools/ui/src/lib/services/migration.service.ts @@ -564,9 +564,9 @@ const configTypesMigration: Migration = { const config = JSON.parse(configRaw); let changed = false; - // Pre-schema configs persisted booleans as the strings "true"/"false", which the - // strict server schema now rejects. Coerce those back to real booleans. No config - // string field holds exactly "true"/"false", so the match is unambiguous. + // Pre-schema configs persisted booleans as "true"/"false" strings; the strict server + // schema rejects them. No config string field holds exactly "true"/"false", so the + // match is unambiguous. for (const key of Object.keys(config)) { if (config[key] === 'true') { config[key] = true; diff --git a/tools/ui/src/lib/stores/agentic.svelte.ts b/tools/ui/src/lib/stores/agentic.svelte.ts index 27491257ac..1a677602f9 100644 --- a/tools/ui/src/lib/stores/agentic.svelte.ts +++ b/tools/ui/src/lib/stores/agentic.svelte.ts @@ -477,7 +477,7 @@ class AgenticStore { conversationId: string; messages: ApiChatMessageData[]; options: AgenticFlowOptions; - tools: ReturnType; + tools: ReturnType; agenticConfig: AgenticConfig; callbacks: AgenticFlowCallbacks; signal?: AbortSignal; diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index 6949047b50..fcd07c4fd6 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -60,6 +60,7 @@ import { ErrorDialogType, MessageRole, MessageType, + ReasoningEffort, StreamConnectionState } from '$lib/enums'; @@ -2334,7 +2335,8 @@ class ChatStore { if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; apiOptions.enableThinking = conversationsStore.getThinkingEnabled(); - apiOptions.reasoningEffort = conversationsStore.getReasoningEffort(); + const effort = conversationsStore.getReasoningEffort(); + if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort; if (hasValue(currentConfig.temperature)) apiOptions.temperature = Number(currentConfig.temperature); diff --git a/tools/ui/src/lib/stores/conversations.svelte.ts b/tools/ui/src/lib/stores/conversations.svelte.ts index 4862022077..b29a900fe3 100644 --- a/tools/ui/src/lib/stores/conversations.svelte.ts +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -47,7 +47,6 @@ import { NON_ALPHANUMERIC_REGEX, MULTIPLE_UNDERSCORE_REGEX, SETTINGS_KEYS, - THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY, REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants'; @@ -84,11 +83,16 @@ class ConversationsStore { /** Pending MCP server overrides for new conversations (before first message) */ pendingMcpServerOverrides = $state(ConversationsStore.loadMcpDefaults()); - /** Global (non-conversation-specific) thinking toggle default */ - pendingThinkingEnabled = $state(ConversationsStore.loadThinkingDefaults()); + /** Global (non-conversation-specific) thinking toggle default, derived from reasoning effort */ + pendingThinkingEnabled = $state(false); /** Global (non-conversation-specific) reasoning effort default */ - pendingReasoningEffort = $state(ConversationsStore.loadReasoningEffortDefault()); + pendingReasoningEffort = $state( + ConversationsStore.loadReasoningEffortDefault() + ); + + /** Last non-off reasoning effort, restored when re-enabling thinking globally */ + private lastNonOffEffort: ReasoningEffort | null = null; private static loadMcpDefaults(): McpServerOverride[] { const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES]; @@ -112,35 +116,14 @@ class ConversationsStore { settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain)); } - /** Load thinking-enabled default from localStorage */ - private static loadThinkingDefaults(): boolean { - if (typeof globalThis.localStorage === 'undefined') return true; - try { - const raw = localStorage.getItem(THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY); - if (!raw) return true; - return raw === 'true'; - } catch { - return true; - } - } - - /** Persist thinking-enabled default to localStorage */ - private saveThinkingDefaults(): void { - if (typeof globalThis.localStorage === 'undefined') return; - localStorage.setItem( - THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY, - this.pendingThinkingEnabled ? 'true' : 'false' - ); - } - /** Load reasoning effort default from localStorage */ - private static loadReasoningEffortDefault(): ReasoningEffort { - if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.MEDIUM; + private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF { + if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF; try { const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY); - return (raw as ReasoningEffort) || ReasoningEffort.MEDIUM; + return (raw as ReasoningEffort | ReasoningEffort.OFF) || ReasoningEffort.OFF; } catch { - return ReasoningEffort.MEDIUM; + return ReasoningEffort.OFF; } } @@ -303,10 +286,17 @@ class ConversationsStore { this.pendingMcpServerOverrides = []; } - // Inherit global thinking default into the new conversation - conversation.thinkingEnabled = this.pendingThinkingEnabled; + // Inherit global thinking/reasoning defaults into the new conversation + const thinkingEnabled = this.getThinkingEnabled(); + conversation.thinkingEnabled = thinkingEnabled; + conversation.reasoningEffort = + this.pendingReasoningEffort === ReasoningEffort.OFF ? undefined : this.pendingReasoningEffort; await DatabaseService.updateConversation(conversation.id, { - thinkingEnabled: this.pendingThinkingEnabled + thinkingEnabled, + reasoningEffort: + this.pendingReasoningEffort === ReasoningEffort.OFF + ? undefined + : this.pendingReasoningEffort }); this.conversations = [conversation, ...this.conversations]; @@ -332,7 +322,6 @@ class ConversationsStore { } this.pendingMcpServerOverrides = []; - this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults(); this.activeConversation = conversation; if (conversation.currNode) { @@ -363,7 +352,7 @@ class ConversationsStore { this.activeMessages = []; // reload defaults so new chats inherit persisted state this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); - this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults(); + this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault(); } /** @@ -794,9 +783,11 @@ class ConversationsStore { */ getThinkingEnabled(): boolean { if (this.activeConversation) { - return this.activeConversation.thinkingEnabled ?? this.pendingThinkingEnabled; + if (this.activeConversation.thinkingEnabled !== undefined) { + return this.activeConversation.thinkingEnabled; + } } - return this.pendingThinkingEnabled; + return this.getReasoningEffort() !== ReasoningEffort.OFF; } /** @@ -806,8 +797,17 @@ class ConversationsStore { */ async setThinkingEnabled(enabled: boolean): Promise { if (!this.activeConversation) { - this.pendingThinkingEnabled = enabled; - this.saveThinkingDefaults(); + if (enabled) { + const effort = this.lastNonOffEffort ?? ReasoningEffort.LOW; + this.pendingReasoningEffort = effort; + this.saveReasoningEffortDefaults(); + } else { + if (this.pendingReasoningEffort !== ReasoningEffort.OFF) { + this.lastNonOffEffort = this.pendingReasoningEffort; + } + this.pendingReasoningEffort = ReasoningEffort.OFF; + this.saveReasoningEffortDefaults(); + } return; } @@ -831,7 +831,7 @@ class ConversationsStore { * Gets the effective reasoning effort for the active conversation. * Returns the conversation override if set, otherwise the global default. */ - getReasoningEffort(): ReasoningEffort { + getReasoningEffort(): ReasoningEffort | ReasoningEffort.OFF { if (this.activeConversation) { return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort; } diff --git a/tools/ui/src/lib/stores/mcp.svelte.ts b/tools/ui/src/lib/stores/mcp.svelte.ts index 37eb563329..a53463ee9a 100644 --- a/tools/ui/src/lib/stores/mcp.svelte.ts +++ b/tools/ui/src/lib/stores/mcp.svelte.ts @@ -12,21 +12,22 @@ * - Lifecycle management (initialize, shutdown) * - Multi-server coordination * - Tool name conflict detection and resolution - * - OpenAI-compatible tool definition generation * - Automatic tool-to-server routing * - Health checks * + * MCP connection state and raw `Tool[]` per server are owned here; the + * OpenAI-compatible wire format for those tools is built in `toolsStore` + * (see {@link toolsStore.mcpEntries} / {@link toolsStore.getEnabledToolsForLLM}). + * * @see MCPService in services/mcp.service.ts for protocol operations */ import { browser } from '$app/environment'; -import { SvelteSet } from 'svelte/reactivity'; import { SETTINGS_KEYS } from '$lib/constants'; import { MCPService } from '$lib/services/mcp.service'; import { config, settingsStore } from '$lib/stores/settings.svelte'; import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte'; import { serverStore } from '$lib/stores/server.svelte'; -import { conversationsStore } from '$lib/stores/conversations.svelte'; import { mode } from 'mode-watcher'; import { parseMcpServerSettings, @@ -40,9 +41,7 @@ import { HealthCheckStatus, MCPRefType, ColorMode, - UrlProtocol, - JsonSchemaType, - ToolCallType + UrlProtocol } from '$lib/enums'; import { DEFAULT_CACHE_TTL_MS, @@ -53,12 +52,10 @@ import { MCP_RECONNECT_BACKOFF_MULTIPLIER, MCP_RECONNECT_INITIAL_DELAY, MCP_RECONNECT_MAX_DELAY, - MCP_RECONNECT_ATTEMPT_TIMEOUT_MS, - RECOMMENDED_MCP_SERVER_IDS + MCP_RECONNECT_ATTEMPT_TIMEOUT_MS } from '$lib/constants'; import type { MCPToolCall, - OpenAIToolDefinition, ServerStatus, ToolExecutionResult, MCPClientConfig, @@ -582,30 +579,10 @@ class MCPStore { } /** - * Recommended MCP server IDs the user opted in to via per-chat overrides. - * Single source of truth for "which recommendations has the user accepted", - * shared by the recommendations hook and the visible-servers getter. - */ - get optedInRecommendationIds(): ReadonlySet { - const ids = new SvelteSet(); - for (const override of conversationsStore.pendingMcpServerOverrides) { - if (RECOMMENDED_MCP_SERVER_IDS.has(override.serverId) && override.enabled) { - ids.add(override.serverId); - } - } - return ids; - } - - /** - * MCP servers selectable in chat-add UIs and the settings page: - * enabled in settings and either non-recommended or explicitly opted in. + * MCP servers selectable in chat-add UIs and the settings page. */ get visibleMcpServers(): MCPServerSettingsEntry[] { - const optedIn = this.optedInRecommendationIds; - return this.getServersSorted().filter( - (server) => - server.enabled && (!RECOMMENDED_MCP_SERVER_IDS.has(server.id) || optedIn.has(server.id)) - ); + return this.getServersSorted().filter((server) => server.enabled); } async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise { @@ -979,73 +956,6 @@ class MCPStore { } } - getToolDefinitionsForLLM(): OpenAIToolDefinition[] { - const tools: OpenAIToolDefinition[] = []; - - for (const connection of this.connections.values()) { - for (const tool of connection.tools) { - const rawSchema = (tool.inputSchema as Record) ?? { - type: JsonSchemaType.OBJECT, - properties: {}, - required: [] - }; - - tools.push({ - type: ToolCallType.FUNCTION as const, - function: { - name: tool.name, - description: tool.description, - parameters: this.normalizeSchemaProperties(rawSchema) - } - }); - } - } - - return tools; - } - - private normalizeSchemaProperties(schema: Record): Record { - if (!schema || typeof schema !== 'object') { - return schema; - } - - const normalized = { ...schema }; - if (normalized.properties && typeof normalized.properties === 'object') { - const props = normalized.properties as Record>; - const normalizedProps: Record> = {}; - for (const [key, prop] of Object.entries(props)) { - if (!prop || typeof prop !== 'object') { - normalizedProps[key] = prop; - continue; - } - const normalizedProp = { ...prop }; - if (!normalizedProp.type && normalizedProp.default !== undefined) { - const defaultVal = normalizedProp.default; - if (typeof defaultVal === 'string') normalizedProp.type = 'string'; - else if (typeof defaultVal === 'number') - normalizedProp.type = Number.isInteger(defaultVal) ? 'integer' : 'number'; - else if (typeof defaultVal === 'boolean') normalizedProp.type = 'boolean'; - else if (Array.isArray(defaultVal)) normalizedProp.type = 'array'; - else if (typeof defaultVal === 'object' && defaultVal !== null) - normalizedProp.type = 'object'; - } - if (normalizedProp.properties) - Object.assign( - normalizedProp, - this.normalizeSchemaProperties(normalizedProp as Record) - ); - if (normalizedProp.items && typeof normalizedProp.items === 'object') - normalizedProp.items = this.normalizeSchemaProperties( - normalizedProp.items as Record - ); - normalizedProps[key] = normalizedProp; - } - normalized.properties = normalizedProps; - } - - return normalized; - } - getToolNames(): string[] { return Array.from(this.toolsIndex.keys()); } diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts index a637819885..dcaab5f42b 100644 --- a/tools/ui/src/lib/stores/tools.svelte.ts +++ b/tools/ui/src/lib/stores/tools.svelte.ts @@ -13,33 +13,6 @@ import { import { SvelteMap, SvelteSet } from 'svelte/reactivity'; /** Stable selection identity for a tool, shared by the disabled set and the permission store */ -function toolKey(source: ToolSource, name: string, serverId?: string): string { - switch (source) { - case ToolSource.MCP: - return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`; - case ToolSource.CUSTOM: - return `custom:${name}`; - case ToolSource.FRONTEND: - return `frontend:${name}`; - default: - return `builtin:${name}`; - } -} - -function mcpDefinition( - name: string, - description: string | undefined, - schema?: Record -): OpenAIToolDefinition { - return { - type: ToolCallType.FUNCTION, - function: { - name, - description, - parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] } - } - }; -} class ToolsStore { private _builtinTools = $state([]); @@ -77,12 +50,96 @@ class ToolsStore { } } + private toolKey(source: ToolSource, name: string, serverId?: string): string { + switch (source) { + case ToolSource.MCP: + return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`; + case ToolSource.CUSTOM: + return `custom:${name}`; + case ToolSource.FRONTEND: + return `frontend:${name}`; + default: + return `builtin:${name}`; + } + } + + private inferTypeFromDefault(value: unknown): string | undefined { + if (typeof value === 'string') return 'string'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number'; + if (Array.isArray(value)) return 'array'; + if (value !== null && typeof value === 'object') return 'object'; + return undefined; + } + + /** + * Recursively normalize a JSON Schema object: infers `type` from `default` + * for properties / items that omit it, and descends into nested `properties` + * and `items`. Returns a new object -- does not mutate the input. + */ + private normalizeJsonSchema(schema: Record): Record { + if (!schema || typeof schema !== 'object') return schema; + + const normalized: Record = { ...schema }; + + if (normalized.properties && typeof normalized.properties === 'object') { + const props = normalized.properties as Record>; + const normalizedProps: Record> = {}; + for (const [key, prop] of Object.entries(props)) { + if (!prop || typeof prop !== 'object') { + normalizedProps[key] = prop; + continue; + } + + const normalizedProp: Record = { ...prop }; + + if (!normalizedProp.type && normalizedProp.default !== undefined) { + const inferred = this.inferTypeFromDefault(normalizedProp.default); + if (inferred) normalizedProp.type = inferred; + } + + if (normalizedProp.properties) { + Object.assign( + normalizedProp, + this.normalizeJsonSchema(normalizedProp as Record) + ); + } + + if (normalizedProp.items && typeof normalizedProp.items === 'object') { + normalizedProp.items = this.normalizeJsonSchema( + normalizedProp.items as Record + ); + } + + normalizedProps[key] = normalizedProp; + } + normalized.properties = normalizedProps; + } + + return normalized; + } + + private mcpDefinition( + name: string, + description: string | undefined, + schema?: Record + ): OpenAIToolDefinition { + return { + type: ToolCallType.FUNCTION, + function: { + name, + description, + parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] } + } + }; + } + get builtinTools(): OpenAIToolDefinition[] { return this._builtinTools; } get mcpTools(): OpenAIToolDefinition[] { - return mcpStore.getToolDefinitionsForLLM(); + return this.mcpEntries().map((e) => e.definition); } get frontendTools(): OpenAIToolDefinition[] { @@ -124,11 +181,22 @@ class ToolsStore { for (const [serverId, connection] of connections) { const serverName = mcpStore.getServerDisplayName(serverId); for (const tool of connection.tools) { - const schema = (tool.inputSchema as Record) ?? undefined; + const rawSchema = (tool.inputSchema as Record) ?? { + type: JsonSchemaType.OBJECT, + properties: {}, + required: [] + }; out.push({ serverId, serverName, - definition: mcpDefinition(tool.name, tool.description, schema) + definition: { + type: ToolCallType.FUNCTION, + function: { + name: tool.name, + description: tool.description, + parameters: this.normalizeJsonSchema(rawSchema) + } + } }); } } @@ -138,7 +206,7 @@ class ToolsStore { out.push({ serverId, serverName, - definition: mcpDefinition(tool.name, tool.description) + definition: this.mcpDefinition(tool.name, tool.description) }); } } @@ -160,14 +228,18 @@ class ToolsStore { for (const def of this._builtinTools) { const name = def.function.name; - push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def }); + push({ + source: ToolSource.BUILTIN, + key: this.toolKey(ToolSource.BUILTIN, name), + definition: def + }); } for (const def of this.frontendTools) { const name = def.function.name; push({ source: ToolSource.FRONTEND, - key: toolKey(ToolSource.FRONTEND, name), + key: this.toolKey(ToolSource.FRONTEND, name), definition: def }); } @@ -178,14 +250,18 @@ class ToolsStore { source: ToolSource.MCP, serverId, serverName, - key: toolKey(ToolSource.MCP, name, serverId), + key: this.toolKey(ToolSource.MCP, name, serverId), definition }); } for (const def of this.customTools) { const name = def.function.name; - push({ source: ToolSource.CUSTOM, key: toolKey(ToolSource.CUSTOM, name), definition: def }); + push({ + source: ToolSource.CUSTOM, + key: this.toolKey(ToolSource.CUSTOM, name), + definition: def + }); } return entries; @@ -233,7 +309,8 @@ class ToolsStore { /** * Enabled tool definitions for sending to the LLM. - * MCP tools keep their normalized schemas from mcpStore. + * MCP tool schemas are normalized here so the wire payload is consistent + * across all four sources (built-in, frontend/sandbox, MCP, custom JSON). * The API identifies tools by name, so a name is sent at most once. */ getEnabledToolsForLLM(): OpenAIToolDefinition[] { @@ -256,7 +333,8 @@ class ToolsStore { for (const def of this._builtinTools) take(def); for (const def of this.frontendTools) take(def); - for (const def of mcpStore.getToolDefinitionsForLLM()) take(def); + // mcpEntries() over mcpStore directly so wire shape stays normalized and aligned with the tools UI. + for (const entry of this.mcpEntries()) take(entry.definition); for (const def of this.customTools) take(def); return result; @@ -308,15 +386,17 @@ class ToolsStore { const connection = mcpStore.getConnections().get(serverId); if (!connection) return; for (const tool of connection.tools) { - this._disabledTools.delete(toolKey(ToolSource.MCP, tool.name, serverId)); + this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId)); } this.persistDisabledTools(); } toggleGroup(group: ToolGroup): void { const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key)); + const target = !allEnabled; for (const tool of group.tools) { - this.setToolEnabled(tool.key, !allEnabled); + if (target) this._disabledTools.delete(tool.key); + else this._disabledTools.add(tool.key); } this.persistDisabledTools(); } @@ -332,7 +412,7 @@ class ToolsStore { tools: { name: string; description?: string }[]; }[] { const result: ReturnType = []; - for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) { + for (const server of mcpStore.visibleMcpServers) { const health = mcpStore.getHealthCheckState(server.id); if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) { result.push({ diff --git a/tools/ui/src/lib/utils/branching.ts b/tools/ui/src/lib/utils/branching.ts index c40abbdd6b..6ff701318a 100644 --- a/tools/ui/src/lib/utils/branching.ts +++ b/tools/ui/src/lib/utils/branching.ts @@ -111,15 +111,7 @@ function findLeafNodeInMap( } /** - * Convenience wrapper around {@link findLeafNodeInMap} for callers that only have - * a flat message array. - * - * Finds the leaf node (message with no children) for a given message branch. - * Traverses down the tree following the last child until reaching a leaf. - * - * @param messages - All messages in the conversation - * @param messageId - Starting message ID to find leaf for - * @returns The leaf node ID, or the original messageId if no children + * Convenience wrapper around {@link findLeafNodeInMap} for callers that have a flat message array. */ export function findLeafNode(messages: readonly DatabaseMessage[], messageId: string): string { const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const)); @@ -225,7 +217,6 @@ export function getMessageSiblings( /** * Builds sibling information for every message in a conversation. - * A single node map is shared across all lookups for O(1) access. * * @param messages - All messages in the conversation * @returns Map of message ID to its sibling information