From 0cf61ad4806da1ac99f544b8f4a775c47d8976e5 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 7 Jul 2026 19:34:58 +0800 Subject: [PATCH] fix(web): auto-enable default thinking effort when switching to an effort-capable model --- .../client/useModelProviderState.ts | 8 +- apps/kimi-web/src/lib/modelThinking.test.ts | 186 ++++++++++++++++++ apps/kimi-web/src/lib/modelThinking.ts | 18 ++ 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 apps/kimi-web/src/lib/modelThinking.test.ts diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 465a4ee9b..0a6308301 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -9,7 +9,7 @@ import { ref, type ComputedRef } from 'vue'; import { getKimiWebApi } from '../../api'; import type { AppMessage, AppModel, AppProvider, AppSession, AppSkill, ThinkingLevel } from '../../api/types'; import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; -import { coerceThinkingForModel } from '../../lib/modelThinking'; +import { coerceThinkingForModel, thinkingLevelForModelSwitch } from '../../lib/modelThinking'; import type { ActivityState } from '../../types'; import type { ExtendedState } from '../useKimiWebClient'; @@ -170,8 +170,11 @@ export function useModelProviderState( */ async function setModel(modelId: string): Promise { const sid = rawState.activeSessionId; - const nextThinking = coerceThinkingForModel(modelById(modelId), rawState.thinking); + const targetModel = modelById(modelId); const prevThinking = rawState.thinking; + const prevModel = sid === undefined ? undefined : rawState.sessions.find((s) => s.id === sid)?.model; + const isSwitch = prevModel !== modelId; + const nextThinking = thinkingLevelForModelSwitch(targetModel, prevThinking, isSwitch); if (!sid) { // New-session draft (onboarding composer): no backend session to update. // Remember the pick — startSessionAndSendPrompt applies it at create time. @@ -181,7 +184,6 @@ export function useModelProviderState( } // Optimistic: show the chosen model immediately, but remember the previous // one so we can roll back if the switch never reaches the daemon. - const prevModel = rawState.sessions.find((s) => s.id === sid)?.model; updateSession(sid, (s) => ({ ...s, model: modelId })); if (nextThinking !== prevThinking) { rawState.thinking = nextThinking; diff --git a/apps/kimi-web/src/lib/modelThinking.test.ts b/apps/kimi-web/src/lib/modelThinking.test.ts new file mode 100644 index 000000000..426cdde8d --- /dev/null +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { + coerceThinkingForModel, + commitLevel, + defaultThinkingLevelFor, + effortLabel, + isThinkingOn, + modelThinkingAvailability, + segmentsFor, + thinkingLevelForModelSwitch, +} from './modelThinking'; +import type { ModelThinkingInfo } from './modelThinking'; + +function model(partial: ModelThinkingInfo): ModelThinkingInfo { + return partial; +} + +describe('modelThinking', () => { + describe('modelThinkingAvailability', () => { + it('defaults to toggle when model is unknown', () => { + expect(modelThinkingAvailability(undefined)).toBe('toggle'); + }); + + it('detects always_thinking capability', () => { + expect(modelThinkingAvailability(model({ capabilities: ['always_thinking'] }))).toBe('always-on'); + }); + + it('detects thinking capability', () => { + expect(modelThinkingAvailability(model({ capabilities: ['thinking'] }))).toBe('toggle'); + }); + + it('detects adaptive thinking', () => { + expect(modelThinkingAvailability(model({ adaptiveThinking: true }))).toBe('toggle'); + }); + + it('marks models without thinking support as unsupported', () => { + expect(modelThinkingAvailability(model({ capabilities: ['vision'] }))).toBe('unsupported'); + }); + }); + + describe('defaultThinkingLevelFor', () => { + it('returns off for unsupported models', () => { + expect(defaultThinkingLevelFor(model({ capabilities: [] }))).toBe('off'); + }); + + it('returns the declared default effort for effort models', () => { + expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high' }))).toBe('high'); + }); + + it('falls back to the middle effort when no default is declared', () => { + expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }))).toBe('high'); + expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high'] }))).toBe('high'); + }); + + it('returns on for boolean thinking models', () => { + expect(defaultThinkingLevelFor(model({ capabilities: ['thinking'] }))).toBe('on'); + }); + }); + + describe('segmentsFor', () => { + it('shows off/on for boolean toggle models', () => { + expect(segmentsFor(model({ capabilities: ['thinking'] }))).toEqual(['on', 'off']); + }); + + it('shows only on for always-on models', () => { + expect(segmentsFor(model({ capabilities: ['always_thinking'] }))).toEqual(['on']); + }); + + it('shows only off for unsupported models', () => { + expect(segmentsFor(model({ capabilities: [] }))).toEqual(['off']); + }); + + it('prefixes off to effort lists for toggle effort models', () => { + expect(segmentsFor(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }))).toEqual(['off', 'low', 'high', 'max']); + }); + + it('omits off for always-on effort models', () => { + expect(segmentsFor(model({ capabilities: ['always_thinking'], supportEfforts: ['low', 'high'] }))).toEqual(['low', 'high']); + }); + }); + + describe('coerceThinkingForModel', () => { + it('keeps the requested level before models are loaded', () => { + expect(coerceThinkingForModel(undefined, 'high')).toBe('high'); + }); + + it('forces unsupported models to off', () => { + expect(coerceThinkingForModel(model({ capabilities: [] }), 'on')).toBe('off'); + }); + + it('forces always-on models to their default level', () => { + expect(coerceThinkingForModel(model({ capabilities: ['always_thinking'] }), 'off')).toBe('on'); + }); + + it('keeps off for boolean toggle models', () => { + expect(coerceThinkingForModel(model({ capabilities: ['thinking'] }), 'off')).toBe('off'); + }); + + it('normalizes non-off levels to on for boolean toggle models', () => { + expect(coerceThinkingForModel(model({ capabilities: ['thinking'] }), 'high')).toBe('on'); + }); + + it('keeps declared effort levels', () => { + expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'high')).toBe('high'); + }); + + it('falls back to default effort for undeclared effort levels', () => { + expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'medium')).toBe('high'); + }); + + it('keeps off for effort models by default', () => { + expect(coerceThinkingForModel(model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'] }), 'off')).toBe('off'); + }); + }); + + const effortModel = model({ capabilities: ['thinking'], supportEfforts: ['low', 'high', 'max'], defaultEffort: 'high' }); + const booleanModel = model({ capabilities: ['thinking'] }); + const alwaysOnModel = model({ capabilities: ['always_thinking'] }); + const unsupportedModel = model({ capabilities: [] }); + + describe('thinkingLevelForModelSwitch', () => { + + it('auto-enables default effort when switching onto an effort model from off', () => { + expect(thinkingLevelForModelSwitch(effortModel, 'off', true)).toBe('high'); + }); + + it('keeps off when re-selecting the current effort model', () => { + expect(thinkingLevelForModelSwitch(effortModel, 'off', false)).toBe('off'); + }); + + it('coerces carried-over levels for effort models during a switch', () => { + expect(thinkingLevelForModelSwitch(effortModel, 'high', true)).toBe('high'); + expect(thinkingLevelForModelSwitch(effortModel, 'medium', true)).toBe('high'); + }); + + it('does not auto-enable for boolean models', () => { + expect(thinkingLevelForModelSwitch(booleanModel, 'off', true)).toBe('off'); + }); + + it('still coerces boolean models to on when carried level is non-off', () => { + expect(thinkingLevelForModelSwitch(booleanModel, 'high', true)).toBe('on'); + }); + + it('forces always-on models on even during re-selection', () => { + expect(thinkingLevelForModelSwitch(alwaysOnModel, 'off', false)).toBe('on'); + }); + + it('forces unsupported models off during a switch', () => { + expect(thinkingLevelForModelSwitch(unsupportedModel, 'high', true)).toBe('off'); + }); + }); + + describe('effortLabel', () => { + it('capitalizes effort names', () => { + expect(effortLabel('off')).toBe('Off'); + expect(effortLabel('high')).toBe('High'); + expect(effortLabel('max')).toBe('Max'); + }); + + it('returns empty string as-is', () => { + expect(effortLabel('')).toBe(''); + }); + }); + + describe('isThinkingOn', () => { + it('returns false for off only', () => { + expect(isThinkingOn('off')).toBe(false); + expect(isThinkingOn('on')).toBe(true); + expect(isThinkingOn('high')).toBe(true); + }); + }); + + describe('commitLevel', () => { + it('keeps off', () => { + expect(commitLevel(effortModel, 'off')).toBe('off'); + }); + + it('resolves on to the model default', () => { + expect(commitLevel(effortModel, 'on')).toBe('high'); + }); + + it('passes concrete efforts through', () => { + expect(commitLevel(effortModel, 'max')).toBe('max'); + }); + }); +}); diff --git a/apps/kimi-web/src/lib/modelThinking.ts b/apps/kimi-web/src/lib/modelThinking.ts index 6f495fd50..19f02583d 100644 --- a/apps/kimi-web/src/lib/modelThinking.ts +++ b/apps/kimi-web/src/lib/modelThinking.ts @@ -112,3 +112,21 @@ export function commitLevel( if (draft === 'on') return defaultThinkingLevelFor(model); return draft; } + +/** + * Thinking level to use when the user picks a model in the switcher. + * Mirrors the TUI model picker: switching onto a different effort-capable + * model from 'off' pre-selects the model's default effort, so the user sees + * the effort control immediately; re-selecting the current model or moving + * to a boolean/unsupported model just coerces the carried-over level. + */ +export function thinkingLevelForModelSwitch( + model: ModelThinkingInfo | undefined, + currentLevel: ThinkingLevel, + isSwitch: boolean, +): ThinkingLevel { + if (isSwitch && currentLevel === 'off' && (model?.supportEfforts?.length ?? 0) > 0) { + return defaultThinkingLevelFor(model); + } + return coerceThinkingForModel(model, currentLevel); +}