diff --git a/.changeset/web-thinking-effort-autoenable.md b/.changeset/web-thinking-effort-autoenable.md new file mode 100644 index 0000000000..75e3ffc100 --- /dev/null +++ b/.changeset/web-thinking-effort-autoenable.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Auto-enable the default thinking effort when switching to a model that supports effort levels in the web UI. diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 390aa69e41..1a6d9fa810 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'; @@ -109,11 +109,19 @@ export function useModelProviderState( ); } - function activeThinkingModel(): AppModel | undefined { + function currentModelId(): string | undefined { const activeSession = rawState.activeSessionId ? rawState.sessions.find((s) => s.id === rawState.activeSessionId) : undefined; - return modelById(activeSession?.model ?? draftModel.value ?? rawState.defaultModel); + const rawModel = + activeSession === undefined + ? draftModel.value ?? rawState.defaultModel + : activeSession.model || rawState.defaultModel; + return modelById(rawModel)?.id ?? rawModel ?? undefined; + } + + function activeThinkingModel(): AppModel | undefined { + return modelById(currentModelId()); } function applyThinkingLevel(level: ThinkingLevel): ThinkingLevel { @@ -192,8 +200,13 @@ 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 prevSessionModel = sid + ? rawState.sessions.find((s) => s.id === sid)?.model + : undefined; + const isSwitch = currentModelId() !== (targetModel?.id ?? 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. @@ -203,7 +216,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; @@ -219,7 +231,7 @@ export function useModelProviderState( // not fail it — but when the daemon is unreachable the request throws here. // Roll the picker back to the real model so the UI can't keep showing the // new one as if the switch succeeded, then surface the failure. - updateSession(sid, (s) => ({ ...s, model: prevModel ?? s.model })); + updateSession(sid, (s) => ({ ...s, model: prevSessionModel ?? s.model })); if (nextThinking !== prevThinking) { rawState.thinking = prevThinking; saveThinkingToStorage(prevThinking); 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 0000000000..6ea0577da6 --- /dev/null +++ b/apps/kimi-web/src/lib/modelThinking.test.ts @@ -0,0 +1,301 @@ +import { computed } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppModel, AppSession } from '../api/types'; +import { + useModelProviderState, + type UseModelProviderStateDeps, +} from '../composables/client/useModelProviderState'; +import type { ExtendedState } from '../composables/useKimiWebClient'; +import { + coerceThinkingForModel, + commitLevel, + defaultThinkingLevelFor, + effortLabel, + isThinkingOn, + modelThinkingAvailability, + segmentsFor, + thinkingLevelForModelSwitch, +} from './modelThinking'; +import type { ModelThinkingInfo } from './modelThinking'; + +const apiMock = vi.hoisted(() => ({ + updateSession: vi.fn(), +})); + +vi.mock('../api', () => ({ + getKimiWebApi: () => apiMock, +})); + +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'); + }); + }); +}); + +describe('useModelProviderState thinking on model selection', () => { + const effortAppModel: AppModel = { + id: 'provider/effort-model', + provider: 'provider', + model: 'effort-model', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }; + const booleanAppModel: AppModel = { + id: 'provider/boolean-model', + provider: 'provider', + model: 'boolean-model', + maxContextSize: 128_000, + capabilities: ['thinking'], + }; + + beforeEach(() => { + apiMock.updateSession.mockReset(); + apiMock.updateSession.mockResolvedValue({}); + }); + + function createState(options: { + activeSession?: Pick; + defaultModel: string; + }): ExtendedState { + return { + activeSessionId: options.activeSession?.id ?? null, + sessions: options.activeSession ? [options.activeSession] : [], + thinking: 'off', + defaultModel: options.defaultModel, + } as ExtendedState; + } + + function createModelProvider(state: ExtendedState) { + const deps: UseModelProviderStateDeps = { + pushOperationFailure: vi.fn(), + refreshSessionStatus: vi.fn().mockResolvedValue(undefined), + persistSessionProfile: vi.fn().mockResolvedValue(undefined), + activity: computed(() => 'idle'), + inFlightPromptSessions: new Set(), + saveThinkingToStorage: vi.fn(), + updateSession: (id, update) => { + state.sessions = state.sessions.map((session) => + session.id === id ? update(session) : session, + ); + }, + updateSessionMessages: vi.fn(), + }; + const provider = useModelProviderState(state, deps); + provider.models.value = [effortAppModel, booleanAppModel]; + return provider; + } + + it('keeps thinking off when re-selecting the default model in a new-session draft', async () => { + const state = createState({ defaultModel: effortAppModel.id }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(state.thinking).toBe('off'); + }); + + it('keeps thinking off when re-selecting an explicit new-session draft model', async () => { + const state = createState({ defaultModel: booleanAppModel.id }); + const provider = createModelProvider(state); + provider.draftModel.value = effortAppModel.id; + + await provider.setModel(effortAppModel.id); + + expect(state.thinking).toBe('off'); + }); + + it('keeps thinking off when an active session inherits the selected default model', async () => { + const state = createState({ + activeSession: { id: 'session-1', model: '' }, + defaultModel: effortAppModel.id, + }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(state.thinking).toBe('off'); + expect(apiMock.updateSession).toHaveBeenCalledWith('session-1', { + model: effortAppModel.id, + thinking: undefined, + }); + }); + + it('enables the default effort when switching from a different model', async () => { + const state = createState({ defaultModel: booleanAppModel.id }); + const provider = createModelProvider(state); + + await provider.setModel(effortAppModel.id); + + expect(state.thinking).toBe('high'); + }); +}); diff --git a/apps/kimi-web/src/lib/modelThinking.ts b/apps/kimi-web/src/lib/modelThinking.ts index 6f495fd508..19f02583d8 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); +}