Skip to content
Merged
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-level-verbatim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, pin the model's catalog default when nothing was chosen, pre-select the target model's default on model switches, and persist explicit picks as the daemon-wide default so new sessions inherit them.
1 change: 0 additions & 1 deletion apps/kimi-web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ The browser web UI for Kimi Code — a peer to the TUI in `apps/kimi-code`. It t
All via `pnpm --filter @moonshot-ai/kimi-web …`:

- `dev` — Vite dev server (port `WEB_PORT`, default 5175; proxies `/api/v1` to `KIMI_SERVER_URL`, default `http://127.0.0.1:58627`).
- `dev:stub` — offline stub daemon (`dev/stub-daemon.mjs`).
- `build` — production build into `dist/`.
- `typecheck` — `vue-tsc --noEmit`.
- `test` — `vitest run` (pure logic tests only; no jsdom / component tests).
Expand Down
5 changes: 1 addition & 4 deletions apps/kimi-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,10 @@ to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript.
## Quick start

```bash
# 1) Against a REAL server (the server must be running and reachable)
# Against a REAL server (the server must be running and reachable)
WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:58627 pnpm -C apps/kimi-web run dev
# …or from the repo root: pnpm dev:web (uses the defaults below)

# 2) Offline / no server — a stub that fakes the server API + event stream
pnpm -C apps/kimi-web run dev:stub # then run dev in another shell

# checks
pnpm -C apps/kimi-web run typecheck # vue-tsc --noEmit
pnpm -C apps/kimi-web run test # vitest (pure logic only)
Expand Down
1 change: 0 additions & 1 deletion apps/kimi-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:stub": "node dev/stub-daemon.mjs",
"build": "vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
Expand Down
23 changes: 15 additions & 8 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import type { SwarmMember } from './composables/swarmGroups';
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 { commitLevel, effectiveThinkingLevel, segmentsFor } from './lib/modelThinking';
import { stripSkillPrefix } from './lib/slashCommands';
import Button from './components/ui/Button.vue';
import IconButton from './components/ui/IconButton.vue';
Expand Down Expand Up @@ -112,19 +112,26 @@ usePageTitle({ running, showAuthGate });
// 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 {
function nextThinkingLevel(current: ThinkingLevel | undefined): ThinkingLevel {
// Identity is the model id — display/model names can collide across providers.
const model = client.models.value.find((m) => m.id === client.status.value.modelId);
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);
// No stored preference means the model default is in effect — cycle from
// there; a level the model doesn't declare (indexOf → -1) starts the cycle
// at the first segment.
const idx = segs.indexOf(effectiveThinkingLevel(model, current));
const next = segs[(idx + 1) % segs.length] ?? segs[0] ?? 'off';
return commitLevel(model, next);
}

// Status panel (/status) renders current client state only — show the
// effective thinking level so "no preference" reads as the model default that
// will actually run, not a blank.
const statusPanelThinking = computed<ThinkingLevel>(() => {
const model = client.models.value.find((m) => m.id === client.status.value.modelId);
return effectiveThinkingLevel(model, client.thinking.value);
});

// First-run onboarding (language + welcome greeting). Shown until the user
// finishes it once; re-openable from the settings popover.
const showOnboarding = ref(!client.onboarded.value);
Expand Down Expand Up @@ -958,7 +965,7 @@ function openPr(url: string): void {
<StatusPanel
v-if="showStatusPanel"
:status="client.status.value"
:thinking="client.thinking.value"
:thinking="statusPanelThinking"
:plan-mode="client.planMode.value"
:swarm-mode="client.swarmMode.value"
:cost-usd="client.sessionCost.value"
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export interface PromptSubmission {
agentId?: string;
/** The daemon requires these on every prompt (per-prompt, not session-level). */
model?: string;
/** Omit to leave the session profile's thinking untouched — the daemon then
* resolves the config/model default (same as an unset [thinking] in the TUI). */
thinking?: ThinkingLevel;
permissionMode?: 'manual' | 'auto' | 'yolo';
planMode?: boolean;
Expand Down
31 changes: 10 additions & 21 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { FileItem } from './MentionMenu.vue';
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
import {
coerceThinkingForModel,
commitLevel,
effectiveThinkingLevel,
effortLabel,
isThinkingOn,
modelThinkingAvailability,
Expand Down Expand Up @@ -612,28 +612,17 @@ const currentModel = computed(() =>
);
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. 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.
// The stored level is shown and submitted verbatim (same as the TUI footer) —
// no coercion against the active model. No stored preference (undefined) shows
// the model default, which is what the daemon will resolve for the prompt. A
// level the model doesn't declare highlights no segment but still shows in the
// suffix.
const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking));
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 isThinkingOn(coercedThinkingLevel.value);
return segs.includes(thinkingLevel.value) ? thinkingLevel.value : '';
});
const thinkingOn = computed(() => isThinkingOn(thinkingLevel.value));
// Single-segment (always-on boolean) or unsupported models can't be changed.
const thinkingReadonly = computed(
() => thinkingAvailability.value === 'unsupported' || thinkingSegments.value.length <= 1,
Expand All @@ -643,7 +632,7 @@ const thinkingReadonly = computed(
const thinkingSuffix = computed(() => {
if (!thinkingOn.value) return '';
const hasEfforts = (currentModel.value?.supportEfforts?.length ?? 0) > 0;
const level = coercedThinkingLevel.value;
const level = thinkingLevel.value;
if (hasEfforts && level !== 'on') return t('composer.thinkingSuffixEffort', { level });
return t('composer.thinkingSuffix');
});
Expand Down
24 changes: 9 additions & 15 deletions apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import type { AppModel, AppSession, ThinkingLevel } from '../../api/types';
import type { ColorScheme } from '../../composables/useKimiWebClient';
import { useKimiWebClient } from '../../composables/useKimiWebClient';
import {
coerceThinkingForModel,
commitLevel,
effectiveThinkingLevel,
effortLabel,
modelThinkingAvailability,
segmentsFor,
Expand Down Expand Up @@ -78,20 +78,14 @@ const currentModel = computed<AppModel | undefined>(() =>
);
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.
// The stored level is shown and submitted verbatim (same as the composer and
// the TUI) — no coercion against the active model. No stored preference shows
// the model default (what the daemon will resolve); a level the model doesn't
// declare simply highlights no segment.
const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking));
const activeThinkingSegment = computed<string>(() => {
const segs = thinkingSegments.value;
const level = coercedThinkingLevel.value;
if (segs.includes(level)) return level;
if (segs.includes('on')) return 'on';
return segs[0] ?? 'off';
return segs.includes(thinkingLevel.value) ? thinkingLevel.value : '';
});
const thinkingOptions = computed(() =>
thinkingSegments.value.map((seg) => ({ value: seg, label: effortLabel(seg) })),
Expand Down Expand Up @@ -274,8 +268,8 @@ watch(
<span
v-else
class="srow-val"
:class="{ dim: activeThinkingSegment === 'off' }"
>{{ activeThinkingSegment === 'off' ? t('status.planOff') : effortLabel(activeThinkingSegment) }}</span>
:class="{ dim: thinkingLevel === 'off' }"
>{{ thinkingLevel === 'off' ? t('status.planOff') : effortLabel(thinkingLevel) }}</span>
</div>

<!-- Plan mode → real toggle switch -->
Expand Down
54 changes: 41 additions & 13 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
ThinkingLevel,
} from '../../api/types';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
import { coerceThinkingForModel, thinkingLevelForModelSwitch } from '../../lib/modelThinking';
import {
defaultThinkingLevelFor,
thinkingLevelForModelSwitch,
thinkingLevelToConfig,
} from '../../lib/modelThinking';
import { beginLocalTurn, settleLocalTurn } from './useWorkspaceState';
import type { ActivityState } from '../../types';
import type { ExtendedState } from '../useKimiWebClient';
Expand Down Expand Up @@ -129,15 +133,24 @@ export function useModelProviderState(
return modelById(rawModel)?.id ?? rawModel ?? undefined;
}

function activeThinkingModel(): AppModel | undefined {
return modelById(currentModelId());
function applyThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel | undefined {
// Stored verbatim — whatever the user picked is what gets submitted to the
// daemon (same as the TUI); no coercion against the active model. Only
// concrete levels are persisted; "no preference" stays in-memory.
rawState.thinking = level;
if (level !== undefined) saveThinkingToStorage(level);
return level;
}

function applyThinkingLevel(level: ThinkingLevel): ThinkingLevel {
const next = coerceThinkingForModel(activeThinkingModel(), level);
rawState.thinking = next;
saveThinkingToStorage(next);
return next;
/** Persist an explicit thinking pick as the daemon-wide default ([thinking]
* in config.toml), mirroring the TUI's persistModelSelection, so sessions
* created by other clients inherit it. Fire-and-forget: the session-level
* and local values have already been applied. Never called for derived
* values (e.g. the loadModels default pin) — only for user actions. */
function persistGlobalThinking(level: ThinkingLevel): void {
void getKimiWebApi()
.setConfig({ thinking: thinkingLevelToConfig(level) })
.catch((error: unknown) => pushOperationFailure('setConfig', error));
}

async function loadSkillsForSession(sessionId: string): Promise<void> {
Expand Down Expand Up @@ -167,7 +180,15 @@ export function useModelProviderState(
try {
const api = getKimiWebApi();
models.value = await api.listModels();
applyThinkingLevel(rawState.thinking);
// No explicit preference: pin the active model's default level (from the
// server catalog) as a concrete value, so what the UI shows, what gets
// submitted, and what the session runs are always the same. In-memory
// only — localStorage stays reserved for levels the user actually
// picked, and a reload re-derives from the then-current model.
if (rawState.thinking === undefined) {
const active = modelById(currentModelId());
if (active !== undefined) rawState.thinking = defaultThinkingLevelFor(active);
Comment on lines +188 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 Defer default pin until the active session is known

When there is no stored thinking preference, this pins rawState.thinking during loadModels(), but the first-load flow calls modelProvider.loadModels() before sessions are loaded and before a deep-linked or auto-selected session is chosen. In that path currentModelId() only sees the global default model, so opening an existing session whose model differs from the default can store e.g. off and later submitPrompt sends that explicit value instead of letting the daemon resolve the session/model default.

Useful? React with 👍 / 👎.

}
} catch (err) {
pushOperationFailure('loadModels', err);
}
Expand Down Expand Up @@ -221,14 +242,16 @@ export function useModelProviderState(
// Remember the pick — startSessionAndSendPrompt applies it at create time.
draftModel.value = modelId;
applyThinkingLevel(nextThinking);
if (nextThinking !== prevThinking && nextThinking !== undefined) {
persistGlobalThinking(nextThinking);
}
return true;
}
// Optimistic: show the chosen model immediately, but remember the previous
// one so we can roll back if the switch never reaches the daemon.
updateSession(sid, (s) => ({ ...s, model: modelId }));
if (nextThinking !== prevThinking) {
rawState.thinking = nextThinking;
saveThinkingToStorage(nextThinking);
applyThinkingLevel(nextThinking);
}
try {
await getKimiWebApi().updateSession(sid, {
Expand All @@ -242,12 +265,16 @@ export function useModelProviderState(
// new one as if the switch succeeded, then surface the failure.
updateSession(sid, (s) => ({ ...s, model: prevSessionModel ?? s.model }));
if (nextThinking !== prevThinking) {
rawState.thinking = prevThinking;
saveThinkingToStorage(prevThinking);
applyThinkingLevel(prevThinking);

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 Clear persisted thinking when rolling back to unset

When a model switch fails after prevThinking was unset, this rollback calls applyThinkingLevel(undefined), but applyThinkingLevel only writes localStorage for concrete levels; the target model default saved by the optimistic call remains in storage. Reproduces by switching models from a fresh/no-preference state while the profile POST fails: the UI rolls back for this session, but after reload prompts send the stale explicit effort instead of omitting thinking.

Useful? React with 👍 / 👎.

}
pushOperationFailure('setModel', err, { sessionId: sid });
return false;
}
// The switch reached the daemon: also persist the thinking pick as the
// daemon-wide default (mirrors the TUI). Skipped on rollback above.
if (nextThinking !== prevThinking && nextThinking !== undefined) {
persistGlobalThinking(nextThinking);
}
// refreshSessionStatus folds the authoritative current model from /status
// back into the session (the profile echo can return ''). Best-effort: a
// failure here does not mean the switch failed, so it must not roll back.
Expand Down Expand Up @@ -419,6 +446,7 @@ export function useModelProviderState(
function setThinking(level: ThinkingLevel): void {
const next = applyThinkingLevel(level);
void persistSessionProfile({ thinking: next });
if (next !== undefined) persistGlobalThinking(next);
}

return {
Expand Down
23 changes: 4 additions & 19 deletions apps/kimi-web/src/composables/client/useSideChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@

import { computed, ref } from 'vue';
import { getKimiWebApi } from '../../api';
import type { AppMessage, AppModel } from '../../api/types';
import type { KimiEventConnection } from '../../api/types';
import type { AppMessage, KimiEventConnection } from '../../api/types';
import { messagesToTurns } from '../messagesToTurns';
import type { ChatTurn } from '../../types';
import { coerceThinkingForModel } from '../../lib/modelThinking';
import type { ExtendedState } from '../useKimiWebClient';

export interface UseSideChatDeps {
Expand All @@ -25,10 +23,6 @@ export interface UseSideChatDeps {
nextOptimisticMsgId: () => string;
connectEventsIfNeeded: () => void;
getEventConn: () => KimiEventConnection | null;
/** Provider model catalog — used to coerce thinking against the parent
* session's model the same way normal prompts do (so a value carried over
* from another model isn't submitted raw). */
models: () => AppModel[];
}

export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
Expand Down Expand Up @@ -207,27 +201,18 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
// Carry the parent's current thinking level, model, and permission so a
// BTW first-turn reflects the same draft/runtime controls the UI shows —
// the parent session profile mirrors them, but the prompt itself is the
// only thing the daemon reads for this turn.
// only thing the daemon reads for this turn. Thinking goes verbatim
// (same as normal prompts and the TUI).
const promptSession = rawState.sessions.find((s) => s.id === sid);
const model =
(promptSession?.model && promptSession.model.length > 0
? promptSession.model
: rawState.defaultModel) ?? undefined;
// Coerce thinking against the parent model the same way a normal prompt
// does (coercePromptThinking in useWorkspaceState): a level carried over
// from another/default model would otherwise be submitted raw and run
// differently from what the UI shows.
const promptModel =
model === undefined
? undefined
: deps.models().find(
(m) => m.model === model || m.id === model || m.displayName === model,
);
const result = await getKimiWebApi().submitPrompt(sid, {
content: [{ type: 'text', text: trimmed }],
agentId,
model,
thinking: coerceThinkingForModel(promptModel, rawState.thinking),
thinking: rawState.thinking,
permissionMode: rawState.permission,
planMode: rawState.planModeBySession[sid] ?? false,
swarmMode: rawState.swarmModeBySession[sid] ?? false,
Expand Down
Loading
Loading