"@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",
}
},
"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": {
"@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",
} from '$lib/constants';
import {
ChatFormActionAddToolsSubmenu,
- ChatFormActionAddMcpServersSubmenu
+ ChatFormActionAddMcpServersSubmenu,
+ ChatFormActionAddReasoningSubmenu
} from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
</Tooltip.Content>
</Tooltip.Root>
- <DropdownMenu.Content align="start" class="w-48">
+ <DropdownMenu.Content align="start" class="w-52">
+ <ChatFormActionAddReasoningSubmenu />
+
+ <DropdownMenu.Separator />
+
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class="h-4 w-4" />
--- /dev/null
+<script lang="ts">
+ 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 } 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';
+ import {
+ modelsStore,
+ checkModelSupportsThinking,
+ supportsThinking,
+ propsCacheVersion,
+ loadedModelIds
+ } from '$lib/stores/models.svelte';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
+ import { isRouterMode } from '$lib/stores/server.svelte';
+ import type { DatabaseMessage } from '$lib/types/database';
+
+ let subOpen = $state(false);
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let modelSupportsThinkingFromMessages = $derived.by(() => {
+ const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
+ if (!modelId) return false;
+
+ const messages = conversationsStore.activeMessages;
+
+ return messages.some(
+ (m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
+ );
+ });
+
+ let modelSupportsThinking = $derived.by(() => {
+ loadedModelIds();
+ propsCacheVersion();
+
+ if (isRouterMode()) {
+ const modelId = modelsStore.selectedModelName || conversationModel;
+ return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
+ }
+
+ return supportsThinking() || modelSupportsThinkingFromMessages;
+ });
+
+ let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
+ let currentEffort = $derived(conversationsStore.getReasoningEffort());
+ let isOff = $derived(!thinkingEnabled);
+
+ function isSelected(item: ReasoningEffortLevel): boolean {
+ if (item.isOff) return isOff;
+ return thinkingEnabled && currentEffort === item.value;
+ }
+
+ function handleSelection(item: ReasoningEffortLevel) {
+ if (item.isOff) {
+ conversationsStore.setThinkingEnabled(false);
+ } else {
+ conversationsStore.setThinkingEnabled(true);
+ conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
+ }
+ subOpen = false;
+ }
+</script>
+
+{#if modelSupportsThinking}
+ <DropdownMenu.Sub bind:open={subOpen}>
+ <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
+ {#if thinkingEnabled}
+ <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+ {:else}
+ <LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
+ {/if}
+
+ <span class="text-sm inline-flex gap-2 {!thinkingEnabled ? 'text-muted-foreground' : ''}">
+ Reasoning
+
+ <span class="capitalize text-muted-foreground">
+ {thinkingEnabled ? currentEffort : 'off'}
+ </span>
+ </span>
+ </DropdownMenu.SubTrigger>
+
+ <DropdownMenu.SubContent
+ class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
+ >
+ {#each REASONING_EFFORT_LEVELS as level (level.value)}
+ <button
+ type="button"
+ class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
+ class:bg-accent={isSelected(level)}
+ onclick={() => handleSelection(level)}
+ >
+ {#if isSelected(level)}
+ <Check class="h-4 w-4 shrink-0 text-foreground" />
+ {:else}
+ <div class="h-4 w-4 shrink-0"></div>
+ {/if}
+
+ <span class="flex-1">{level.label}</span>
+
+ {#if !level.isOff}
+ <span class="text-[11px] text-muted-foreground opacity-60">
+ {REASONING_EFFORT_TOKENS[level.value] === -1
+ ? 'Unlimited'
+ : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
+ </span>
+ {/if}
+
+ {#if level.hasInfo}
+ <Tooltip.Root>
+ <Tooltip.Trigger>
+ <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+ </Tooltip.Trigger>
+ <Tooltip.Content side="left">
+ <p>Maximum reasoning effort with extended context usage</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ </button>
+ {/each}
+ </DropdownMenu.SubContent>
+ </DropdownMenu.Sub>
+{/if}
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 {
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;
+ });
</script>
<div
style="container-type: inline-size"
>
{#if showAddButton}
- <div class="mr-auto flex items-center gap-3">
+ <div class="mr-auto flex items-center gap-2">
<ChatFormActionsAdd
{disabled}
{hasAudioModality}
</div>
{/if}
- <div class="flex items-center gap-2">
- <ChatFormReasoningToggle />
+ <div class="flex items-center gap-1.5">
+ {#if hasProcessedTokens}
+ <ChatFormContextGauge />
+ {/if}
{#if showModelSelector}
<ChatFormActionModels
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';
+ import { DIALOG_SUBMENU_CONTENT } from '$lib/constants/css-classes';
import {
modelsStore,
checkModelSupportsThinking,
{#if modelSupportsThinking}
<DropdownMenu.Sub bind:open={subOpen}>
- <DropdownMenu.SubTrigger
- class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors outline-none hover:bg-accent focus:bg-accent"
- >
+ <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if thinkingEnabled}
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
{:else}
{/if}
</DropdownMenu.SubTrigger>
- <DropdownMenu.SubContent
- class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
- >
+ <DropdownMenu.SubContent class={DIALOG_SUBMENU_CONTENT}>
{#each REASONING_EFFORT_LEVELS as level (level.value)}
<button
type="button"
- class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
+ class="flex w-full cursor-pointer items-center gap-2"
class:bg-accent={isSelected(level)}
onclick={() => handleSelection(level)}
>
- {#if isSelected(level)}
- <Check class="h-4 w-4 shrink-0 text-foreground" />
- {:else}
- <div class="h-4 w-4 shrink-0"></div>
- {/if}
-
- <span class="flex-1">{level.label}</span>
+ <span class="flex-1 text-left">{level.label}</span>
{#if !level.isOff}
<span class="text-[11px] text-muted-foreground opacity-60">
</Tooltip.Content>
</Tooltip.Root>
{/if}
+
+ {#if isSelected(level)}
+ <Check class="h-4 w-4 shrink-0 text-foreground" />
+ {/if}
</button>
{/each}
</DropdownMenu.SubContent>
+++ /dev/null
-<script lang="ts">
- 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 { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
- import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
- import type { ReasoningEffortLevel } from '$lib/types';
- import {
- modelsStore,
- checkModelSupportsThinking,
- supportsThinking,
- propsCacheVersion,
- loadedModelIds
- } from '$lib/stores/models.svelte';
- import { chatStore } from '$lib/stores/chat.svelte';
- import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
- 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
- );
- });
-
- // Check if model supports thinking. Primary: chat template from /props.
- // Fallback: message history (reasoning content in assistant messages).
- let modelSupportsThinking = $derived.by(() => {
- loadedModelIds();
- propsCacheVersion();
-
- if (isRouterMode()) {
- const modelId = modelsStore.selectedModelName || conversationModel;
- return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
- }
-
- // In non-router mode, use the built-in supportsThinking
- return supportsThinking() || modelSupportsThinkingFromMessages;
- });
-
- // Check if current item is selected
- function isSelected(item: ReasoningEffortLevel): boolean {
- if (item.isOff) {
- return isOff;
- }
- return thinkingEnabled && currentEffort === item.value;
- }
-
- function handleSelection(item: ReasoningEffortLevel) {
- if (item.isOff) {
- conversationsStore.setThinkingEnabled(false);
- } else {
- conversationsStore.setThinkingEnabled(true);
- conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
- }
- subOpen = false;
- }
-</script>
-
-{#if modelSupportsThinking}
- <DropdownMenu.Root bind:open={subOpen}>
- <Tooltip.Root>
- <Tooltip.Trigger>
- <DropdownMenu.Trigger
- class={[
- 'flex h-6 w-6 cursor-pointer items-center justify-center rounded-full p-0 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
- thinkingEnabled ? 'bg-amber-400/10 hover:bg-amber-400/20' : 'bg-muted'
- ]}
- aria-label={`${tooltipText}. Click to configure.`}
- >
- {#if thinkingEnabled}
- <Lightbulb class="h-3 w-3 text-amber-400" />
- {:else}
- <LightbulbOff class="h-3 w-3 text-muted-foreground" />
- {/if}
- </DropdownMenu.Trigger>
- </Tooltip.Trigger>
-
- <Tooltip.Content>
- <p class="capitalize">{tooltipText}</p>
- </Tooltip.Content>
- </Tooltip.Root>
-
- <DropdownMenu.Content
- align="start"
- class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none"
- >
- <div class="mb-2 px-2.5 text-sm font-medium">Reasoning effort</div>
-
- {#each REASONING_EFFORT_LEVELS as level (level.value)}
- <button
- type="button"
- class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
- class:bg-accent={isSelected(level)}
- onclick={() => handleSelection(level)}
- >
- {#if isSelected(level)}
- <Check class="h-4 w-4 shrink-0 text-foreground" />
- {:else}
- <div class="h-4 w-4 shrink-0"></div>
- {/if}
-
- <span class="flex-1">{level.label}</span>
-
- {#if !level.isOff}
- <span class="text-[11px] text-muted-foreground opacity-60">
- {REASONING_EFFORT_TOKENS[level.value] === -1
- ? 'Unlimited'
- : `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
- </span>
- {/if}
-
- {#if level.hasInfo}
- <Tooltip.Root>
- <Tooltip.Trigger>
- <Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
- </Tooltip.Trigger>
- <Tooltip.Content side="left">
- <p>Maximum reasoning effort with extended context usage</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- </button>
- {/each}
- </DropdownMenu.Content>
- </DropdownMenu.Root>
-{/if}
--- /dev/null
+<script lang="ts">
+ import { untrack } from 'svelte';
+ import * as HoverCard from '$lib/components/ui/hover-card';
+ import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
+ import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
+ import { formatParameters } from '$lib/utils/formatters';
+ import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
+ import ContextGaugeDial from './ContextGaugeDial.svelte';
+ import ContextGaugeDetails from './ContextGaugeDetails.svelte';
+ import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
+ import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
+
+ const gauge = useContextGauge();
+
+ $effect(() => {
+ const conv = activeConversation();
+ untrack(() => chatStore.setActiveProcessingConversation(conv?.id ?? null));
+ });
+
+ $effect(() => {
+ const conv = activeConversation();
+ const messages = activeMessages() as DatabaseMessage[];
+ if (!conv) return;
+ if (isLoading() || isChatStreaming()) return;
+
+ if (messages.length === 0) {
+ untrack(() => chatStore.clearProcessingState(conv.id));
+ return;
+ }
+
+ untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conv.id));
+ });
+
+ $effect(() => {
+ gauge.startMonitoring();
+ });
+
+ const showProgressBar = $derived(
+ gauge.contextTotal !== null &&
+ gauge.contextTotal > 0 &&
+ (gauge.activeModelId !== null || gauge.isActiveModelLoaded)
+ );
+</script>
+
+<HoverCard.Root>
+ <HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
+ <ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
+ </HoverCard.Trigger>
+
+ <HoverCard.Content
+ side="bottom"
+ class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
+ >
+ <div class="flex flex-col gap-2">
+ <div class="flex items-center gap-2">
+ <span class="font-medium">Context</span>
+ <span class="text-muted-foreground">·</span>
+ <span class="font-mono text-muted-foreground">
+ {formatParameters(gauge.contextUsed)}
+ / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
+ </span>
+ </div>
+
+ {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
+ <ContextGaugeLoadModel
+ modelId={gauge.activeModelId}
+ isLoading={gauge.isActiveModelLoading}
+ onLoad={gauge.loadModel}
+ />
+ {:else if showProgressBar}
+ <div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
+ <div
+ class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
+ gauge.colorLevel
+ )}"
+ style="width: {gauge.contextPercent}%"
+ ></div>
+ </div>
+
+ <div class="flex justify-between text-xs text-muted-foreground">
+ <span>
+ <span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
+ </span>
+ <span>
+ {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
+ </span>
+ </div>
+ {:else}
+ <div class="text-xs text-muted-foreground">No context info available</div>
+ {/if}
+
+ {#if gauge.hasAnyUsage}
+ <ContextGaugeDetails
+ currentRead={gauge.currentRead}
+ currentFresh={gauge.currentFresh}
+ currentCache={gauge.currentCache}
+ currentOutput={gauge.currentOutput}
+ kvTotal={gauge.kvTotal}
+ cumulativeRead={gauge.cumulativeRead}
+ cumulativeOutput={gauge.cumulativeOutput}
+ cumulativeCacheTotal={gauge.cumulativeCacheTotal}
+ averageTokensPerSecond={gauge.averageTokensPerSecond}
+ transientDetails={gauge.transientDetails}
+ />
+ {/if}
+ </div>
+ </HoverCard.Content>
+</HoverCard.Root>
--- /dev/null
+<script lang="ts">
+ interface Props {
+ label: string;
+ value: string;
+ subtitle?: string;
+ }
+
+ let { label, value, subtitle }: Props = $props();
+</script>
+
+<div class="grid gap-1.5">
+ <div class="flex items-baseline justify-between">
+ <span class="text-muted-foreground">{label}</span>
+ <span class="font-mono text-muted-foreground">{value}</span>
+ </div>
+
+ {#if subtitle}
+ <div class="text-[10px] leading-tight text-muted-foreground/70">{subtitle}</div>
+ {/if}
+</div>
--- /dev/null
+<script lang="ts">
+ import { ChevronDown } from '@lucide/svelte';
+ import * as Collapsible from '$lib/components/ui/collapsible';
+ import { STATS_UNITS } from '$lib/constants';
+ import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
+
+ interface Props {
+ currentRead: number;
+ currentFresh: number;
+ currentCache: number;
+ currentOutput: number;
+ kvTotal: number;
+ cumulativeRead: number;
+ cumulativeOutput: number;
+ cumulativeCacheTotal: number;
+ averageTokensPerSecond: number | null;
+ transientDetails: string[];
+ }
+
+ let {
+ currentRead,
+ currentFresh,
+ currentCache,
+ currentOutput,
+ kvTotal,
+ cumulativeRead,
+ cumulativeOutput,
+ cumulativeCacheTotal,
+ averageTokensPerSecond,
+ transientDetails
+ }: Props = $props();
+
+ let open = $state(false);
+
+ const hasCumulative = $derived(cumulativeRead > 0 || cumulativeOutput > 0);
+ const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
+</script>
+
+<Collapsible.Root bind:open class="mt-3 border-t border-border/50 pt-4">
+ <Collapsible.Trigger
+ class="flex w-full cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+ >
+ <span>Token usage details</span>
+
+ <ChevronDown class={'ml-auto h-3 w-3 transition-transform' + (open ? ' rotate-180' : '')} />
+ </Collapsible.Trigger>
+
+ <Collapsible.Content class="flex flex-col gap-4 text-xs pt-4">
+ {#if hasCumulative}
+ <div>
+ <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+ Across all turns
+ </h3>
+
+ <div class="flex flex-col gap-2">
+ {#if cumulativeRead > 0}
+ <ContextGaugeDetailRow
+ label="Prompt tokens evaluated"
+ value={`${cumulativeRead.toLocaleString()} tok`}
+ subtitle={cumulativeCacheTotal > 0
+ ? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
+ : undefined}
+ />
+ {/if}
+ {#if cumulativeOutput > 0}
+ <ContextGaugeDetailRow
+ label="Tokens generated"
+ value={`${cumulativeOutput.toLocaleString()} tok`}
+ />
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ {#if hasCurrent}
+ <div>
+ <h3 class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2">
+ This turn · KV cache
+ </h3>
+
+ <div class="flex flex-col gap-2">
+ {#if currentRead > 0}
+ <ContextGaugeDetailRow
+ label="Prompt"
+ value={`${currentRead.toLocaleString()} tok`}
+ subtitle={currentCache > 0
+ ? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
+ : undefined}
+ />
+ {/if}
+
+ {#if currentOutput > 0}
+ <ContextGaugeDetailRow
+ label="Generated"
+ value={`${currentOutput.toLocaleString()} tok`}
+ />
+ {/if}
+
+ <div class="pt-1 mt-0.5 border-t border-border/30">
+ <div class="flex justify-between">
+ <span class="text-muted-foreground">KV cache total</span>
+ <span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ {/if}
+
+ {#if averageTokensPerSecond !== null}
+ <div class="pt-1.5 mt-1 border-t border-border/30">
+ <ContextGaugeDetailRow
+ label="Avg speed"
+ value={`${averageTokensPerSecond.toFixed(1)}${STATS_UNITS.TOKENS_PER_SECOND}`}
+ />
+ </div>
+ {/if}
+
+ {#each transientDetails as detail (detail)}
+ <div class="font-mono text-muted-foreground">{detail}</div>
+ {/each}
+ </Collapsible.Content>
+</Collapsible.Root>
--- /dev/null
+<script lang="ts">
+ import type { ColorLevel } from './context-gauge';
+ import { colorLevelTextClass } from './context-gauge';
+
+ interface Props {
+ percent: number | null;
+ level: ColorLevel;
+ size?: 'sm' | 'md';
+ }
+
+ let { percent, level, size = 'sm' }: Props = $props();
+
+ const RADIUS = 11;
+ const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
+
+ const strokeLevelClass = $derived(colorLevelTextClass(level));
+ const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
+ const strokeWidth = $derived(size === 'md' ? 4 : 3);
+</script>
+
+<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
+ <circle
+ cx="16"
+ cy="16"
+ r={RADIUS}
+ stroke="currentColor"
+ stroke-opacity="0.1"
+ stroke-width={strokeWidth}
+ />
+
+ <circle
+ cx="16"
+ cy="16"
+ r={RADIUS}
+ class="transition-colors duration-300 {strokeLevelClass}"
+ stroke="currentColor"
+ stroke-width={strokeWidth}
+ stroke-linecap="round"
+ stroke-dasharray={CIRCUMFERENCE}
+ stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
+ transform="rotate(-90 16 16)"
+ />
+</svg>
--- /dev/null
+<script lang="ts">
+ import { Loader2 } from '@lucide/svelte';
+ import { Button } from '$lib/components/ui/button';
+
+ interface Props {
+ modelId: string | null;
+ isLoading: boolean;
+ onLoad: () => void;
+ }
+
+ let { modelId, isLoading, onLoad }: Props = $props();
+</script>
+
+{#if modelId !== null && !isLoading}
+ <div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
+ <span>Available context size is only visible once the model is loaded.</span>
+ <Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
+ </div>
+{:else if isLoading}
+ <div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
+ <span>Loading model...</span>
+ </div>
+{/if}
--- /dev/null
+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';
+ }
+}
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean;
+ isLastUserMessage?: boolean;
+ nextAssistantMessage?: DatabaseMessage | null;
siblingInfo?: ChatMessageSiblingInfo | null;
}
message,
toolMessages = [],
isLastAssistantMessage = false,
+ isLastUserMessage = false,
+ nextAssistantMessage = null,
siblingInfo = null
}: Props = $props();
<ChatMessageUser
class={className}
{deletionInfo}
+ {isLastUserMessage}
{message}
+ {nextAssistantMessage}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
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';
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
{toolMessages}
isStreaming={isChatStreaming()}
{isLastAssistantMessage}
- highlightTurns={highlightAgenticTurns}
/>
{/if}
{:else}
<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}
{#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}
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;
assistantMessages: number;
messageTypes: string[];
} | null;
+ isLastUserMessage?: boolean;
+ nextAssistantMessage?: DatabaseMessage | null;
showDeleteDialog: boolean;
onEdit: () => void;
onDelete: () => void;
message,
siblingInfo = null,
deletionInfo,
+ isLastUserMessage = false,
+ nextAssistantMessage = null,
showDeleteDialog,
onEdit,
onDelete,
// 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
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
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 {
onDelete
}: Props = $props();
- const processingInfoCtx = getProcessingInfoContext();
- let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
-
const editCtx = useMessageEditContext({
getContent: () => content,
getExtras: () => extras,
<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}
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({});
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
{/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}
.agentic-content {
display: flex;
flex-direction: column;
- gap: 0.5rem;
width: 100%;
max-width: 48rem;
+ gap: 1rem;
}
- .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-content > :global(*),
+ .agentic-turn > :global(*) {
+ min-width: 0;
}
- .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;
+ .agentic-text {
+ width: 100%;
}
.turn-stats {
- margin-top: 0.75rem;
- padding-top: 0.5rem;
border-top: 1px solid hsl(var(--muted) / 0.5);
}
</style>
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';
agenticTimings?: ChatMessageAgenticTimings;
onActiveViewChange?: (view: ChatMessageStatsView) => void;
hideSummary?: boolean;
+ mode?: ChatMessageStatisticsMode;
}
let {
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 &&
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);
{/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}
+ {#if isSwitchable}
+ <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: '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'
+ view: ChatMessageStatsView.GENERATION,
+ icon: Sparkles,
+ label: 'Generation',
+ tooltipText: isGenerationDisabled ? 'Waiting for tokens...' : 'Generation',
+ disabled: isGenerationDisabled
})}
- {#if !hideSummary}
+ {#if hasAgenticStats}
{@render viewButton({
- view: ChatMessageStatsView.SUMMARY,
- icon: Layers,
- label: 'Summary',
- tooltipText: 'Agentic summary'
+ 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}
- </div>
+ </div>
+ {/if}
<div class="flex items-center gap-1 px-2">
{#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats}
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
/>
- {:else if hasPromptStats}
+ {:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
message: DatabaseMessage;
toolMessages: DatabaseMessage[];
isLastAssistantMessage: boolean;
+ isLastUserMessage: boolean;
+ nextAssistantMessage: DatabaseMessage | null;
siblingInfo: ChatMessageSiblingInfo;
}> = [];
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>
{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}
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';
errorDialog,
isLoading,
isChatStreaming,
- isEditing,
- activeProcessingState
+ isEditing
} from '$lib/stores/chat.svelte';
import {
conversationsStore,
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);
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';
}}
/>
{/if}
-
- {#if showProcessingInfo}
- <ChatScreenProcessingInfo />
- {/if}
</div>
<ChatScreenForm
+++ /dev/null
-<script lang="ts">
- 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));
- }
- }
- });
-</script>
-
-<div
- class={[
- 'chat-processing-info-container pointer-events-none relative w-full hidden md:block',
- processingVisible && 'visible'
- ]}
->
- <div class="chat-processing-info-content absolute bottom-4 left-1/2 -translate-x-1/2">
- {#each processingDetails as detail (detail)}
- <span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>
- {/each}
- </div>
-</div>
-
-<style>
- .chat-processing-info-container {
- position: sticky;
- top: 0;
- z-index: 10;
- padding: 0 1rem 0.75rem;
- opacity: 0;
- transform: translateY(50%);
- transition:
- opacity 300ms ease-out,
- transform 300ms ease-out;
- }
-
- .chat-processing-info-container.visible {
- opacity: 1;
- transform: translateY(0);
- }
-
- .chat-processing-info-content {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 1rem;
- justify-content: center;
- max-width: 48rem;
- margin: 0 auto;
- }
-
- .chat-processing-info-detail {
- color: var(--muted-foreground);
- font-size: 0.75rem;
- padding: 0.25rem 0.75rem;
- border-radius: 0.375rem;
- font-family:
- ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
- white-space: nowrap;
- }
-
- @media (max-width: 768px) {
- .chat-processing-info-content {
- gap: 0.5rem;
- }
-
- .chat-processing-info-detail {
- font-size: 0.7rem;
- padding: 0.2rem 0.5rem;
- }
- }
-</style>
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.
*/
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.
open = value;
onToggle?.();
}}
- class={className}
+ class="{className} my-0!"
>
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
</script>
<div
- class="code-preview-wrapper rounded-lg border border-border bg-muted {className}"
- style="max-height: {maxHeight}; max-width: {maxWidth};"
+ class="code-preview-wrapper min-w-0 max-w-full overflow-x-auto rounded-lg border border-border bg-muted {className}"
+ style="max-height: {maxHeight}; {maxWidth ? `max-width: ${maxWidth};` : ''}"
>
<!-- Needs to be formatted as single line for proper rendering -->
<pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
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';
);
let addedServers = $state<MCPServerSettingsEntry[]>([]);
+ 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('');
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);
}
function enableSelected() {
+ didAddAny = true;
localStorage.setItem(MCP_SERVERS_ADDED_TO_CHAT_LOCALSTORAGE_KEY, 'true');
for (const server of RECOMMENDED_MCP_SERVERS) {
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');
<Dialog.Footer>
<Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Not now</Button>
- <Button variant="default" size="sm" onclick={enableSelected}>Add selected</Button>
+ <Button
+ variant="default"
+ size="sm"
+ onclick={enableSelected}
+ disabled={footerLabel === 'Continue'}>{footerLabel}</Button
+ >
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
- <McpServerIdentity
- iconClass="h-4 w-4"
- iconRounded="rounded-sm"
- showVersion={false}
- displayName={group.label}
- {faviconUrl}
- />
+ {#if group.source === 'mcp'}
+ <McpServerIdentity
+ iconClass="h-4 w-4"
+ iconRounded="rounded-sm"
+ showVersion={false}
+ displayName={group.label}
+ {faviconUrl}
+ />
+ {:else}
+ <TruncatedText text={group.label} class="font-medium" />
+ {/if}
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
--- /dev/null
+<script lang="ts">
+ import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+ import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+ import HoverCardPortal from './hover-card-portal.svelte';
+ import type { ComponentProps } from 'svelte';
+
+ let {
+ ref = $bindable(null),
+ class: className,
+ align = 'center',
+ sideOffset = 4,
+ portalProps,
+ ...restProps
+ }: HoverCardPrimitive.ContentProps & {
+ portalProps?: WithoutChildrenOrChild<ComponentProps<typeof HoverCardPortal>>;
+ } = $props();
+</script>
+
+<HoverCardPortal {...portalProps}>
+ <HoverCardPrimitive.Content
+ bind:ref
+ data-slot="hover-card-content"
+ {align}
+ {sideOffset}
+ class={cn(
+ 'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 origin-(--transform-origin) outline-hidden',
+ className
+ )}
+ {...restProps}
+ />
+</HoverCardPortal>
--- /dev/null
+<script lang="ts">
+ import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+ let { ...restProps }: HoverCardPrimitive.PortalProps = $props();
+</script>
+
+<HoverCardPrimitive.Portal {...restProps} />
--- /dev/null
+<script lang="ts">
+ import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+ let { ref = $bindable(null), ...restProps }: HoverCardPrimitive.TriggerProps = $props();
+</script>
+
+<HoverCardPrimitive.Trigger bind:ref data-slot="hover-card-trigger" {...restProps} />
--- /dev/null
+<script lang="ts">
+ import { LinkPreview as HoverCardPrimitive } from 'bits-ui';
+
+ let { open = $bindable(false), ...restProps }: HoverCardPrimitive.RootProps = $props();
+</script>
+
+<HoverCardPrimitive.Root bind:open {...restProps} />
--- /dev/null
+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
+};
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';
`;
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
+export const DIALOG_SUBMENU_CONTENT = 'w-60';
* Keys match the ReasoningEffort enum values for type-safe lookups.
*/
export const REASONING_EFFORT_LABELS: Record<string, string> = {
+ [ReasoningEffort.OFF]: 'Off',
[ReasoningEffort.LOW]: 'Low',
[ReasoningEffort.MEDIUM]: 'Medium',
[ReasoningEffort.HIGH]: 'High',
};
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' },
// 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',
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
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',
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',
/** 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`;
setChatSettingsConfigContext,
type ChatSettingsConfigContext
} from './chat-settings-config.context';
-
-export {
- getProcessingInfoContext,
- setProcessingInfoContext,
- type ProcessingInfoContext
-} from './processing-info.context';
+++ /dev/null
-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);
-}
SUMMARY = 'summary'
}
+export enum ChatMessageStatisticsMode {
+ SWITCHABLE = 'switchable',
+ READING = 'reading',
+ GENERATION = 'generation'
+}
+
/**
* Connection state of a streamed completion, drives the resume status indicator.
*/
export {
ChatMessageStatsView,
+ ChatMessageStatisticsMode,
StreamConnectionState,
ContentPartType,
ConversationSelectionMode,
* These values are sent to the server and mapped to token budgets.
*/
export enum ReasoningEffort {
+ OFF = 'off',
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
--- /dev/null
+/**
+ * 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<void>;
+ 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<typeof useProcessingState>['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()
+ };
+}
// 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));
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';
return activeProcessingState();
});
- // Track last known state for keepStatsVisible functionality
$effect(() => {
if (processingState && isMonitoring) {
lastKnownState = processingState;
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 {
if (stream && conversationId) {
headers['X-Conversation-Id'] = streamIdentity(conversationId, options.model);
}
+
const response = await fetch(API_CHAT.COMPLETIONS, {
method: 'POST',
headers,
*
* @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<string>} Promise that resolves to the generated content string
* @throws {Error} if the response cannot be parsed or is malformed
*/
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;
conversationId: string;
messages: ApiChatMessageData[];
options: AgenticFlowOptions;
- tools: ReturnType<typeof mcpStore.getToolDefinitionsForLLM>;
+ tools: ReturnType<typeof toolsStore.getEnabledToolsForLLM>;
agenticConfig: AgenticConfig;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
ErrorDialogType,
MessageRole,
MessageType,
+ ReasoningEffort,
StreamConnectionState
} from '$lib/enums';
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);
NON_ALPHANUMERIC_REGEX,
MULTIPLE_UNDERSCORE_REGEX,
SETTINGS_KEYS,
- THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
} from '$lib/constants';
/** Pending MCP server overrides for new conversations (before first message) */
pendingMcpServerOverrides = $state<McpServerOverride[]>(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<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
+ pendingReasoningEffort = $state<ReasoningEffort | ReasoningEffort.OFF>(
+ 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];
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;
}
}
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];
}
this.pendingMcpServerOverrides = [];
- this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
this.activeConversation = conversation;
if (conversation.currNode) {
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
- this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
+ this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
}
/**
*/
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;
}
/**
*/
async setThinkingEnabled(enabled: boolean): Promise<void> {
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;
}
* 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;
}
* - 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,
HealthCheckStatus,
MCPRefType,
ColorMode,
- UrlProtocol,
- JsonSchemaType,
- ToolCallType
+ UrlProtocol
} from '$lib/enums';
import {
DEFAULT_CACHE_TTL_MS,
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,
}
/**
- * 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<string> {
- const ids = new SvelteSet<string>();
- 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<boolean> {
}
}
- getToolDefinitionsForLLM(): OpenAIToolDefinition[] {
- const tools: OpenAIToolDefinition[] = [];
-
- for (const connection of this.connections.values()) {
- for (const tool of connection.tools) {
- const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
- 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<string, unknown>): Record<string, unknown> {
- if (!schema || typeof schema !== 'object') {
- return schema;
- }
-
- const normalized = { ...schema };
- if (normalized.properties && typeof normalized.properties === 'object') {
- const props = normalized.properties as Record<string, Record<string, unknown>>;
- const normalizedProps: Record<string, Record<string, unknown>> = {};
- 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<string, unknown>)
- );
- if (normalizedProp.items && typeof normalizedProp.items === 'object')
- normalizedProp.items = this.normalizeSchemaProperties(
- normalizedProp.items as Record<string, unknown>
- );
- normalizedProps[key] = normalizedProp;
- }
- normalized.properties = normalizedProps;
- }
-
- return normalized;
- }
-
getToolNames(): string[] {
return Array.from(this.toolsIndex.keys());
}
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<string, unknown>
-): OpenAIToolDefinition {
- return {
- type: ToolCallType.FUNCTION,
- function: {
- name,
- description,
- parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
- }
- };
-}
class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
}
}
+ 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<string, unknown>): Record<string, unknown> {
+ if (!schema || typeof schema !== 'object') return schema;
+
+ const normalized: Record<string, unknown> = { ...schema };
+
+ if (normalized.properties && typeof normalized.properties === 'object') {
+ const props = normalized.properties as Record<string, Record<string, unknown>>;
+ const normalizedProps: Record<string, Record<string, unknown>> = {};
+ for (const [key, prop] of Object.entries(props)) {
+ if (!prop || typeof prop !== 'object') {
+ normalizedProps[key] = prop;
+ continue;
+ }
+
+ const normalizedProp: Record<string, unknown> = { ...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<string, unknown>)
+ );
+ }
+
+ if (normalizedProp.items && typeof normalizedProp.items === 'object') {
+ normalizedProp.items = this.normalizeJsonSchema(
+ normalizedProp.items as Record<string, unknown>
+ );
+ }
+
+ normalizedProps[key] = normalizedProp;
+ }
+ normalized.properties = normalizedProps;
+ }
+
+ return normalized;
+ }
+
+ private mcpDefinition(
+ name: string,
+ description: string | undefined,
+ schema?: Record<string, unknown>
+ ): 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[] {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
- const schema = (tool.inputSchema as Record<string, unknown>) ?? undefined;
+ const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
+ 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)
+ }
+ }
});
}
}
out.push({
serverId,
serverName,
- definition: mcpDefinition(tool.name, tool.description)
+ definition: this.mcpDefinition(tool.name, tool.description)
});
}
}
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
});
}
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;
/**
* 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[] {
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;
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();
}
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
- 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({
}
/**
- * 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));
/**
* 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