Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-thinking-effort-autoenable.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 5 additions & 3 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -184,8 +184,11 @@ export function useModelProviderState(
*/
async function setModel(modelId: string): Promise<void> {
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;
Comment on lines +189 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve draft selections when detecting model switches

When there is no active session (the new-session/onboarding composer), prevModel is always undefined even though the current picker value comes from draftModel or rawState.defaultModel. If the user has thinking set to off and clicks the already-current effort-capable model in the full picker, isSwitch becomes true and the next line auto-enables the model's default effort, so simply re-selecting the current draft/default model silently changes the thinking level for the first prompt instead of preserving off.

Useful? React with 👍 / 👎.

Comment on lines +189 to +190

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare against the effective current model

Here prevModel is undefined in the new-session draft state, and can also be empty when a session relies on the daemon default model, even though the picker/status treats draftModel ?? defaultModel as the current selection. In those cases, clicking the already-selected effort-capable model while thinking is off makes isSwitch true and thinkingLevelForModelSwitch turns thinking back on, so re-selecting the current model no longer preserves off. Derive isSwitch from the effective current model used for display/coercion, not only from session.model.

Useful? React with 👍 / 👎.

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.
Expand All @@ -195,7 +198,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;
Expand Down
186 changes: 186 additions & 0 deletions apps/kimi-web/src/lib/modelThinking.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
18 changes: 18 additions & 0 deletions apps/kimi-web/src/lib/modelThinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading