From cf512566dc89d7a2bdefd43f6d4b85a1c18e93a7 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 22 Jul 2026 23:09:49 +0200 Subject: [PATCH] ui: Add a "Default" option for the reasoning selector (#25846) * ui: add Default reasoning option that defers to the server The webui always injected enable_thinking, overriding the chat template default and the --reasoning flag, breaking models that reason unconditionally (e.g. Gemma 4 E4B) on a fresh client. Default sends nothing so the server decides, Off and effort levels force the value as before. All choices are remembered. Also remove the boolean thinking API from the conversations store and drop ChatFormReasoningEffortSubmenu.svelte (dead code). * ui: close the whole menu tree on reasoning level selection The reasoning levels were raw buttons inside the SubContent, so selecting one only closed the submenu via manual state while the root dropdown stayed open. DropdownMenu.Item closes the full tree on select like the sibling entries and brings native keyboard navigation. * ui: prevent the add menu tooltip from flashing when the dropdown closes --- .../ChatFormActionAddDropdown.svelte | 4 +- .../ChatFormActionAddReasoningSubmenu.svelte | 27 ++-- .../ChatFormActionAddSheet.svelte | 6 +- .../ChatFormReasoningEffortSubmenu.svelte | 127 ------------------ .../ui/src/lib/constants/reasoning-effort.ts | 4 +- .../src/lib/enums/reasoning-effort.enums.ts | 1 + .../lib/hooks/use-reasoning-menu.svelte.ts | 19 +-- tools/ui/src/lib/services/chat.service.ts | 12 +- tools/ui/src/lib/stores/chat.svelte.ts | 7 +- .../ui/src/lib/stores/conversations.svelte.ts | 97 +++---------- tools/ui/src/lib/types/reasoning.ts | 1 - 11 files changed, 67 insertions(+), 238 deletions(-) delete mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte 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 699605fd8..905c2fe6f 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 @@ -71,7 +71,9 @@
- + + {#snippet child({ props })} {#if reasoning.modelSupportsThinking} - + {#if reasoning.thinkingEnabled} - {:else} + {:else if reasoning.isOff} + {:else} + {/if} - {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'} + {reasoning.currentEffort} @@ -37,14 +37,13 @@ > {#each reasoning.levels as level (level.value)} {@const tokenLabel = reasoning.tokenLabel(level)} - + {/each} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte index c533acc75..021e0e453 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte @@ -116,14 +116,16 @@ {#if reasoning.thinkingEnabled} - {:else} + {:else if reasoning.isOff} + {:else} + {/if} Reasoning - {reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'} + {reasoning.currentEffort} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte deleted file mode 100644 index 0a4277e94..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -{#if modelSupportsThinking} - - - {#if thinkingEnabled} - - {:else} - - {/if} - - Thinking - - {#if thinkingEnabled} - {currentEffort} - {:else} - off - {/if} - - - - {#each REASONING_EFFORT_LEVELS as level (level.value)} - - {/each} - - -{/if} diff --git a/tools/ui/src/lib/constants/reasoning-effort.ts b/tools/ui/src/lib/constants/reasoning-effort.ts index 28a24420e..f21ea588a 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.DEFAULT]: 'Default', [ReasoningEffort.OFF]: 'Off', [ReasoningEffort.LOW]: 'Low', [ReasoningEffort.MEDIUM]: 'Medium', @@ -14,7 +15,8 @@ export const REASONING_EFFORT_LABELS: Record = { }; export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [ - { value: ReasoningEffort.OFF, label: 'Off', isOff: true }, + { value: ReasoningEffort.DEFAULT, label: 'Default' }, + { value: ReasoningEffort.OFF, label: 'Off' }, { value: ReasoningEffort.LOW, label: 'Low' }, { value: ReasoningEffort.MEDIUM, label: 'Medium' }, { value: ReasoningEffort.HIGH, label: 'High' }, diff --git a/tools/ui/src/lib/enums/reasoning-effort.enums.ts b/tools/ui/src/lib/enums/reasoning-effort.enums.ts index 172f118e7..6bf86ed4e 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 { + DEFAULT = 'default', OFF = 'off', LOW = 'low', MEDIUM = 'medium', diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts index 6a459e66b..ce9b77884 100644 --- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -17,6 +17,7 @@ import { isRouterMode } from '$lib/stores/server.svelte'; export interface UseReasoningMenuReturn { readonly modelSupportsThinking: boolean; readonly thinkingEnabled: boolean; + readonly isOff: boolean; readonly currentEffort: ReasoningEffort; readonly levels: ReasoningEffortLevel[]; isSelected(level: ReasoningEffortLevel): boolean; @@ -59,8 +60,10 @@ export function useReasoningMenu(): UseReasoningMenuReturn { return supportsThinking() || modelSupportsThinkingFromMessages; }); - const thinkingEnabled = $derived(conversationsStore.getThinkingEnabled()); const currentEffort = $derived(conversationsStore.getReasoningEffort()); + const thinkingEnabled = $derived( + currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT + ); return { get modelSupportsThinking() { @@ -69,6 +72,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn { get thinkingEnabled() { return thinkingEnabled; }, + get isOff() { + return currentEffort === ReasoningEffort.OFF; + }, get currentEffort() { return currentEffort; }, @@ -76,20 +82,15 @@ export function useReasoningMenu(): UseReasoningMenuReturn { return REASONING_EFFORT_LEVELS; }, isSelected(level: ReasoningEffortLevel): boolean { - if (level.isOff) return !thinkingEnabled; - return thinkingEnabled && currentEffort === level.value; + return currentEffort === level.value; }, tokenLabel(level: ReasoningEffortLevel): string | null { - if (level.isOff) return null; + if (level.value === ReasoningEffort.DEFAULT) return 'Model default'; const tokens = REASONING_EFFORT_TOKENS[level.value]; + if (tokens === undefined) return null; return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`; }, select(level: ReasoningEffortLevel): void { - if (level.isOff) { - conversationsStore.setThinkingEnabled(false); - return; - } - conversationsStore.setThinkingEnabled(true); conversationsStore.setReasoningEffort(level.value as ReasoningEffort); } }; diff --git a/tools/ui/src/lib/services/chat.service.ts b/tools/ui/src/lib/services/chat.service.ts index 15f4cc3b5..d2455614f 100644 --- a/tools/ui/src/lib/services/chat.service.ts +++ b/tools/ui/src/lib/services/chat.service.ts @@ -271,10 +271,14 @@ export class ChatService { const reasoningBudgetTokens = enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1; - requestBody.chat_template_kwargs = { - ...(requestBody.chat_template_kwargs ?? {}), - enable_thinking: enableThinking - }; + // an explicit user choice injects the kwarg, otherwise it is omitted so + // the server default applies (--reasoning flag or chat template) + if (enableThinking !== undefined) { + requestBody.chat_template_kwargs = { + ...(requestBody.chat_template_kwargs ?? {}), + enable_thinking: enableThinking + }; + } if (reasoningBudgetTokens >= 0) { requestBody.thinking_budget_tokens = reasoningBudgetTokens; diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index ab418ed9c..801d6c838 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -2373,9 +2373,12 @@ class ChatStore { if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true; - apiOptions.enableThinking = conversationsStore.getThinkingEnabled(); + // an explicit reasoning choice overrides the server default, DEFAULT sends nothing const effort = conversationsStore.getReasoningEffort(); - if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort; + if (effort !== ReasoningEffort.DEFAULT) { + apiOptions.enableThinking = effort !== ReasoningEffort.OFF; + 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 5c54e72ac..9649cfe03 100644 --- a/tools/ui/src/lib/stores/conversations.svelte.ts +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -80,25 +80,17 @@ class ConversationsStore { /** Whether the store has been initialized */ isInitialized = $state(false); - /** 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; - - /** Load reasoning effort default from localStorage */ - private static loadReasoningEffortDefault(): ReasoningEffort | ReasoningEffort.OFF { - if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.OFF; + /** Load reasoning effort default from localStorage, DEFAULT defers to the server */ + private static loadReasoningEffortDefault(): ReasoningEffort { + if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT; try { const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY); - return (raw as ReasoningEffort | ReasoningEffort.OFF) || ReasoningEffort.OFF; + return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT; } catch { - return ReasoningEffort.OFF; + return ReasoningEffort.DEFAULT; } } @@ -235,17 +227,10 @@ class ConversationsStore { // servers without a per-conversation override to `mcpServers[i].enabled`, // and only explicit toggles are stored on the conversation. - // 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; + // Inherit the global reasoning default into the new conversation + conversation.reasoningEffort = this.pendingReasoningEffort; await DatabaseService.updateConversation(conversation.id, { - thinkingEnabled, - reasoningEffort: - this.pendingReasoningEffort === ReasoningEffort.OFF - ? undefined - : this.pendingReasoningEffort + reasoningEffort: this.pendingReasoningEffort }); this.conversations = [conversation, ...this.conversations]; @@ -793,63 +778,21 @@ class ConversationsStore { await this.setMcpServerOverride(serverId, undefined); } - /** - * Gets the effective thinking-enabled state for the active conversation. - * Returns the conversation override if set, otherwise the global default. - */ - getThinkingEnabled(): boolean { - if (this.activeConversation) { - if (this.activeConversation.thinkingEnabled !== undefined) { - return this.activeConversation.thinkingEnabled; - } - } - return this.getReasoningEffort() !== ReasoningEffort.OFF; - } - - /** - * Sets the thinking-enabled state for the active conversation. - * If no conversation exists, stores the global default. - * @param enabled - The enabled state - */ - async setThinkingEnabled(enabled: boolean): Promise { - if (!this.activeConversation) { - 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; - } - - this.activeConversation = { - ...this.activeConversation, - thinkingEnabled: enabled - }; - - await DatabaseService.updateConversation(this.activeConversation.id, { - thinkingEnabled: enabled - }); - - const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); - if (convIndex !== -1) { - this.conversations[convIndex].thinkingEnabled = enabled; - this.conversations = [...this.conversations]; - } - } - /** * Gets the effective reasoning effort for the active conversation. * Returns the conversation override if set, otherwise the global default. + * DEFAULT means no override is sent and the server decides. */ - getReasoningEffort(): ReasoningEffort | ReasoningEffort.OFF { + getReasoningEffort(): ReasoningEffort { if (this.activeConversation) { - return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort; + if (this.activeConversation.reasoningEffort !== undefined) { + return this.activeConversation.reasoningEffort; + } + // conversations created before the tri-state store an explicit + // opt-out only as thinkingEnabled = false + if (this.activeConversation.thinkingEnabled === false) { + return ReasoningEffort.OFF; + } } return this.pendingReasoningEffort; } @@ -857,7 +800,7 @@ class ConversationsStore { /** * Sets the reasoning effort for the active conversation. * If no conversation exists, stores the global default. - * @param effort - The effort level ('low' | 'medium' | 'high' | 'max') + * @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max') */ async setReasoningEffort(effort: ReasoningEffort): Promise { if (!this.activeConversation) { diff --git a/tools/ui/src/lib/types/reasoning.ts b/tools/ui/src/lib/types/reasoning.ts index b72a3b77f..417c79609 100644 --- a/tools/ui/src/lib/types/reasoning.ts +++ b/tools/ui/src/lib/types/reasoning.ts @@ -1,6 +1,5 @@ export interface ReasoningEffortLevel { value: string; label: string; - isOff?: boolean; hasInfo?: boolean; }