diff --git a/.changeset/web-thinking-effort-levels.md b/.changeset/web-thinking-effort-levels.md new file mode 100644 index 000000000..247088c19 --- /dev/null +++ b/.changeset/web-thinking-effort-levels.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d89debde7..0cec2807e 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -37,6 +37,7 @@ import { openDialogCount } from './composables/dialogStack'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; +import { coerceThinkingForModel, commitLevel, segmentsFor } from './lib/modelThinking'; import Button from './components/ui/Button.vue'; import IconButton from './components/ui/IconButton.vue'; import Icon from './components/ui/Icon.vue'; @@ -87,10 +88,22 @@ const { showAuthGate, blinkAuthLogo } = useAuthGate({ client, authLogoRef }); // spinner while the agent is running so activity is visible at a glance. usePageTitle({ running, showAuthGate }); -// Thinking is on/off (TUI parity — no effort-level cycling). The /thinking -// command flips between off and the backend default effort ('high'). +// The /thinking slash command has no popover anchor, so it steps to the next +// segment for the active model (effort models cycle through their declared +// levels; boolean models flip on/off; unsupported stays off). function nextThinkingLevel(current: ThinkingLevel): ThinkingLevel { - return current === 'off' ? 'high' : 'off'; + const raw = client.status.value.modelId ?? client.status.value.model ?? ''; + const model = client.models.value.find( + (m) => m.id === raw || m.model === raw || m.displayName === client.status.value.model, + ); + const segs = segmentsFor(model); + // Coerce the stored level against the active model before indexing, so a + // stale value (e.g. 'on' from a boolean model) doesn't resolve to index -1 + // and jump to 'off' instead of advancing from the model's default effort. + const coerced = coerceThinkingForModel(model, current); + const idx = segs.indexOf(coerced); + const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off'; + return commitLevel(model, next); } // First-run onboarding (language + welcome greeting). Shown until the user @@ -939,6 +952,7 @@ function openPr(url: string): void { v-model="showMobileSettings" :status="client.status.value" :thinking="client.thinking.value" + :models="client.models.value" :plan-mode="client.planMode.value" :swarm-mode="client.swarmMode.value" :color-scheme="client.colorScheme.value" diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 626c5e587..74a1d44bc 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -716,6 +716,8 @@ export function toAppModel(wire: WireModel): AppModel { displayName: wire.display_name, maxContextSize: wire.max_context_size, capabilities: wire.capabilities, + supportEfforts: wire.support_efforts, + defaultEffort: wire.default_effort, }; } diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index e50500fe8..fe6f8b4de 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -343,6 +343,8 @@ export interface WireModel { display_name?: string; max_context_size: number; capabilities?: string[]; + support_efforts?: string[]; + default_effort?: string; } export interface WireProvider { diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index fd8d63f74..156bc34fb 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -193,7 +193,17 @@ export interface CompactionMarkerMetadata { // Prompt // --------------------------------------------------------------------------- -export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +/** + * Runtime thinking level. 'off' disables extended thinking; 'on' is the + * enable signal for legacy boolean models (those without `support_efforts`); + * any other string is a model-declared effort level (e.g. 'low'/'high'/'max'). + * + * `support_efforts` is the single source of truth for which concrete levels a + * model accepts; providers silently drop unknown efforts rather than erroring. + * Collapses to `string` at runtime — this is a semantic marker, not a closed + * enum. Mirrors kosong's `ThinkingEffort`. + */ +export type ThinkingLevel = 'off' | 'on' | (string & {}); export interface PromptSubmission { content: AppMessageContent[]; @@ -550,6 +560,11 @@ export interface AppModel { maxContextSize: number; /** Optional capability tags (e.g. ["vision", "thinking"]) */ capabilities?: string[]; + /** Effort levels this model supports for extended thinking (e.g. ["low", "high", "max"]). + Sourced from the model catalog (managed) or config [models..overrides]. */ + supportEfforts?: readonly string[]; + /** Catalog-declared default effort for extended thinking. */ + defaultEffort?: string; } export interface AppProvider { diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 9d00f61b6..f86c3240e 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -8,7 +8,14 @@ import { buildSlashItems, parseSlash } from '../../lib/slashCommands'; import type { FileItem } from './MentionMenu.vue'; import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types'; import type { AppModel, AppSkill, ThinkingLevel } from '../../api/types'; -import { modelThinkingAvailability } from '../../lib/modelThinking'; +import { + coerceThinkingForModel, + commitLevel, + effortLabel, + isThinkingOn, + modelThinkingAvailability, + segmentsFor, +} from '../../lib/modelThinking'; import { useInputHistory } from '../../composables/useInputHistory'; import { useSlashMenu } from '../../composables/useSlashMenu'; import { useMentionMenu } from '../../composables/useMentionMenu'; @@ -592,15 +599,45 @@ const currentModel = computed(() => { ); }); const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); -const thinkingToggleable = computed(() => thinkingAvailability.value === 'toggle'); +const thinkingSegments = computed(() => segmentsFor(currentModel.value)); +// The persisted level can be stale relative to the active model (e.g. a +// boolean 'on'/'off' carried over when selecting another session). Coerce it +// against the current model before deriving display state so an always-on +// model never shows "thinking: off" and an effort model shows its concrete +// level instead of the bare "thinking" tag. +const coercedThinkingLevel = computed(() => + coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'), +); +// Runtime level clamped to the segments this model actually offers, so a +// carried-over value never highlights a segment that doesn't exist here. +const activeThinkingSegment = computed(() => { + const segs = thinkingSegments.value; + const level = coercedThinkingLevel.value; + if (segs.includes(level)) return level; + if (segs.includes('on')) return 'on'; + return segs[0] ?? 'off'; +}); const thinkingOn = computed(() => { if (thinkingAvailability.value === 'always-on') return true; if (thinkingAvailability.value === 'unsupported') return false; - return (props.thinking ?? 'off') !== 'off'; + return isThinkingOn(coercedThinkingLevel.value); +}); +// Single-segment (always-on boolean) or unsupported models can't be changed. +const thinkingReadonly = computed( + () => thinkingAvailability.value === 'unsupported' || thinkingSegments.value.length <= 1, +); +// Footer-style suffix: effort models show the concrete level; boolean models +// keep the plain "thinking" tag; off shows nothing. +const thinkingSuffix = computed(() => { + if (!thinkingOn.value) return ''; + const hasEfforts = (currentModel.value?.supportEfforts?.length ?? 0) > 0; + const level = coercedThinkingLevel.value; + if (hasEfforts && level !== 'on') return t('composer.thinkingSuffixEffort', { level }); + return t('composer.thinkingSuffix'); }); -function toggleThinking(): void { - if (!thinkingToggleable.value) return; - emit('setThinking', thinkingOn.value ? 'off' : 'high'); +function setThinkingSegment(draft: string): void { + if (thinkingReadonly.value) return; + emit('setThinking', commitLevel(currentModel.value, draft)); } // Plan toggle @@ -928,7 +965,7 @@ function selectModel(modelId: string): void { @keydown.space.prevent="toggleDropdown" > {{ status.model }} - {{ t('composer.thinkingSuffix') }} + {{ thinkingSuffix }} @@ -986,19 +1023,31 @@ function selectModel(modelId: string): void {
- - + {{ t('status.modeNotSupported') }} +
+ +
+
@@ -1551,6 +1600,73 @@ function selectModel(modelId: string): void { margin: 3px 0; } +/* Thinking level segmented control — sits inside the model dropdown. */ +.md-thinking { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 7px; + border-radius: var(--radius-sm); +} +.md-thinking .md-name { + font-family: var(--mono); + font-size: var(--ui-font-size); + color: var(--color-text); + flex: none; +} +.md-thinking .md-note { + margin-left: auto; +} +.effort-segments { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 1px; + padding: 2px; + background: var(--color-surface-sunken); + border: 1px solid var(--color-line); + border-radius: var(--radius-md); +} +.effort-seg { + appearance: none; + border: none; + background: none; + cursor: pointer; + font-family: var(--mono); + font-size: var(--ui-font-size-xs); + line-height: 1; + color: var(--color-text-muted); + padding: 4px 9px; + border-radius: var(--radius-sm); + white-space: nowrap; + transition: background 0.12s, color 0.12s, box-shadow 0.12s; +} +.effort-seg:hover:not(:disabled):not(.is-active) { + background: var(--color-surface-raised); + color: var(--color-text); +} +.effort-seg:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} +.effort-seg.is-active { + background: var(--color-accent); + color: var(--color-text-on-accent); + box-shadow: var(--shadow-xs); + font-weight: 500; +} +.effort-seg:disabled { + cursor: default; +} +.md-thinking.is-readonly .effort-segments { + opacity: 0.62; +} +.md-thinking.is-readonly .effort-seg.is-active { + background: var(--color-surface-raised); + color: var(--color-text-muted); + box-shadow: none; +} + /* Permission dropdown — anchored to the toolbar left side */ .perm-dropdown { position: absolute; @@ -1872,6 +1988,19 @@ function selectModel(modelId: string): void { .md-section { font-size: var(--ui-font-size); } + .md-thinking { + flex-wrap: wrap; + row-gap: 6px; + } + .md-thinking .effort-segments { + margin-left: 0; + width: 100%; + justify-content: space-between; + } + .md-thinking .effort-seg { + flex: 1; + padding: 5px 6px; + } .pd-name { font-size: var(--ui-font-size); } diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index a8288cabb..07697e723 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -9,8 +9,15 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import type { ConversationStatus, PermissionMode } from '../../types'; -import type { ThinkingLevel } from '../../api/types'; +import type { AppModel, ThinkingLevel } from '../../api/types'; import type { ColorScheme } from '../../composables/useKimiWebClient'; +import { + coerceThinkingForModel, + commitLevel, + effortLabel, + modelThinkingAvailability, + segmentsFor, +} from '../../lib/modelThinking'; import BottomSheet from '../dialogs/BottomSheet.vue'; import LanguageSwitcher from '../settings/LanguageSwitcher.vue'; import SegmentedControl from '../ui/SegmentedControl.vue'; @@ -30,8 +37,16 @@ const props = withDefaults( conversationToc?: boolean; /** Server version from GET /api/v1/meta, shown as a read-only row. */ serverVersion?: string; + /** Available models — used to derive the current model's thinking segments. */ + models?: AppModel[]; }>(), - { colorScheme: 'system', uiFontSize: 14, authReady: false, serverVersion: '' }, + { + colorScheme: 'system', + uiFontSize: 14, + authReady: false, + serverVersion: '', + models: () => [], + }, ); const emit = defineEmits<{ @@ -54,7 +69,32 @@ function onColorScheme(v: string): void { const PERM_MODES: PermissionMode[] = ['manual', 'auto', 'yolo']; -const thinkingLevel = computed(() => props.thinking ?? 'high'); +const currentModel = computed(() => { + const raw = props.status?.modelId ?? props.status?.model ?? ''; + return props.models?.find( + (m) => m.id === raw || m.model === raw || m.displayName === props.status?.model, + ); +}); +const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value)); +const thinkingSegments = computed(() => segmentsFor(currentModel.value)); +// The persisted level can be stale relative to the active model (e.g. 'on' +// from a boolean model, or 'off' while viewing an always-on effort model). +// Coerce it before computing the active segment so the mobile sheet shows and +// selects the same model-aware default the composer and prompt submission use. +const coercedThinkingLevel = computed(() => + coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'), +); +// Runtime level clamped to the segments this model actually offers. +const activeThinkingSegment = computed(() => { + const segs = thinkingSegments.value; + const level = coercedThinkingLevel.value; + if (segs.includes(level)) return level; + if (segs.includes('on')) return 'on'; + return segs[0] ?? 'off'; +}); +const thinkingOptions = computed(() => + thinkingSegments.value.map((seg) => ({ value: seg, label: effortLabel(seg) })), +); const planOn = computed(() => props.planMode === true); const swarmOn = computed(() => props.swarmMode === true); @@ -82,9 +122,8 @@ const ctxValue = computed(() => props.status.ctxMax > 0 ? `${kFmt(props.status.ctxUsed)}/${kFmt(props.status.ctxMax)}` : t('status.statusNone'), ); -function cycleThinking(): void { - // On/off toggle (TUI parity). 'high' = the backend default effort. - emit('setThinking', thinkingLevel.value === 'off' ? 'high' : 'off'); +function setThinkingSegment(value: string): void { + emit('setThinking', commitLevel(currentModel.value, value)); } function cyclePermission(): void { @@ -126,14 +165,28 @@ function onLogout(): void { - - + + {{ activeThinkingSegment === 'off' ? t('status.planOff') : effortLabel(activeThinkingSegment) }} +