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-per-model-thinking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Remember the thinking level per model, fixing an empty and unresponsive thinking picker when the active model does not support a previously stored level.
9 changes: 4 additions & 5 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -633,11 +633,10 @@ const currentModel = computed(() =>
);
const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value));
const thinkingSegments = computed(() => segmentsFor(currentModel.value));
// 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.
// The client resolves the level per model (the model's stored pick when still
// declared, else the catalog default), so what arrives here is valid for the
// active model and highlights its segment. An undeclared level can only appear
// transiently, before the catalog loads, and simply highlights no segment.
const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking));
const activeThinkingSegment = computed(() => {
const segs = thinkingSegments.value;
Expand Down
8 changes: 4 additions & 4 deletions apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ const currentModel = computed<AppModel | undefined>(() =>
);
const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value));
const thinkingSegments = computed(() => segmentsFor(currentModel.value));
// 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.
// The client resolves the level per model (the model's stored pick when still
// declared, else the catalog default), so what arrives here is valid for the
// active model. An undeclared level can only appear transiently, before the
// catalog loads, and simply highlights no segment.
const thinkingLevel = computed(() => effectiveThinkingLevel(currentModel.value, props.thinking));
const activeThinkingSegment = computed<string>(() => {
const segs = thinkingSegments.value;
Expand Down
130 changes: 109 additions & 21 deletions apps/kimi-web/src/composables/client/useModelProviderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// model pick. Cross-dependencies (failure reporting, status refresh, activity,
// in-flight set, thinking storage) are injected by the facade.

import { ref, type ComputedRef } from 'vue';
import { ref, watch, type ComputedRef } from 'vue';
import { getKimiWebApi } from '../../api';
import type {
AppMessage,
Expand All @@ -19,6 +19,7 @@ import type {
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
import {
defaultThinkingLevelFor,
levelDeclaredBy,
thinkingLevelForModelSwitch,
thinkingLevelToConfig,
} from '../../lib/modelThinking';
Expand All @@ -28,6 +29,12 @@ import type { ExtendedState } from '../useKimiWebClient';

const STARRED_MODELS_STORAGE_KEY = STORAGE_KEYS.starredModels;

/** Sentinel thrown to abort a skill activation when the prerequisite profile
* persist failed — persistSessionProfile already surfaced that failure, so
* the catch skips activating without reporting a second, synthetic error.
* (An actual Error instance: oxlint only-throw-error.) */
const PROFILE_PERSIST_FAILED = new Error('profile persist failed');

function loadStarredModelsFromStorage(): string[] {
try {
const raw = safeGetString(STARRED_MODELS_STORAGE_KEY);
Expand Down Expand Up @@ -67,9 +74,16 @@ export interface UseModelProviderStateDeps {
opts?: { title?: string; message?: string; sessionId?: string },
) => void;
refreshSessionStatus: (sessionId: string) => Promise<void>;
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<void>;
/** Persist profile fields to the daemon. Resolves false (after surfacing the
* failure itself) when the daemon rejected the patch — awaited callers that
* order strictly after the profile must NOT proceed on false. */
persistSessionProfile: (patch: PersistSessionProfilePatch, sessionId?: string) => Promise<boolean>;
activity: ComputedRef<ActivityState>;
saveThinkingToStorage: (v: ThinkingLevel) => void;
/** Read the persisted thinking pick for a model (undefined = never picked
* for that model). Storage is keyed by model id. */
loadThinkingForModel: (modelId: string) => ThinkingLevel | undefined;
/** Persist an explicit thinking pick under the given model id. */
saveThinkingToStorage: (modelId: string, level: ThinkingLevel) => void;
/** Replace one session in place (matched by id). Owned by the facade so the
* model module never assigns rawState.sessions directly. */
updateSession: (id: string, update: (session: AppSession) => AppSession) => void;
Expand All @@ -89,6 +103,7 @@ export function useModelProviderState(
refreshSessionStatus,
persistSessionProfile,
activity,
loadThinkingForModel,
saveThinkingToStorage,
updateSession,
updateSessionMessages,
Expand Down Expand Up @@ -131,15 +146,61 @@ export function useModelProviderState(
return modelById(rawModel)?.id ?? rawModel ?? undefined;
}

/**
* The level a model should run at: the user's stored pick for THIS model when
* the model still declares it, else the model's catalog default. Validation
* against the catalog entry is what keeps a stale or foreign level (picked
* for another model, or dropped by a catalog update) from reaching the UI and
* the daemon.
*/
function thinkingLevelForModel(model: AppModel): ThinkingLevel {
const stored = loadThinkingForModel(model.id);
if (stored !== undefined && levelDeclaredBy(model, stored)) return stored;
return defaultThinkingLevelFor(model);
Comment on lines +156 to +159

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 Honor thinking picks when localStorage is unavailable

When localStorage is unavailable or setItem throws—for example because of a storage policy or quota—the safe storage helpers swallow the failure, so setThinking updates only rawState.thinking. This resolver then rereads an empty map and returns the catalog default; all prompt, steer, BTW, and skill paths now use that result, causing the UI to show the user's selected level while the daemon receives a different default. Keep the per-model choices in memory as the runtime source of truth and use localStorage only for hydration and best-effort persistence.

Useful? React with 👍 / 👎.

Comment on lines +156 to +159

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 Synchronize the displayed level with cross-tab storage

When another tab changes the thinking level for the same model, this resolver sees the new localStorage map on every submission, but rawState.thinking remains unchanged because the facade only installs a storage listener for unread state. Consequently, one tab can continue highlighting low while silently submitting max after the other tab changes it. Either handle storage events for the thinking key and re-resolve the displayed state, or avoid using an unseen cross-tab value for runtime submission.

Useful? React with 👍 / 👎.

}

/** thinkingLevelForModel by model id, for paths that submit a prompt for a
* session other than the active one (queued drain, steer): the level must
* come from the prompt's OWN model, not from rawState.thinking, which always
* tracks the active session's model. Undefined when the id is not in the
* catalog (caller falls back to the active value, same as before). */
function thinkingLevelForModelId(modelId: string | undefined): ThinkingLevel | undefined {
if (modelId === undefined) return undefined;
const model = modelById(modelId);
return model === undefined ? undefined : thinkingLevelForModel(model);
}

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.
// The explicit-picker path (setThinking) — the ONLY writer of thinking
// storage. Model switches (setModel) and passive resolution update
// rawState.thinking in-memory instead, so a level the user never actually
// picked (e.g. a derived catalog default) is never mistaken for a stored
// choice. Persisted under the CURRENT model id — per-model storage keeps a
// pick from leaking onto models that never declared it. Only concrete
// levels are persisted; "no preference" stays in-memory.
rawState.thinking = level;
if (level !== undefined) saveThinkingToStorage(level);
const modelId = currentModelId();
if (level !== undefined && modelId !== undefined) saveThinkingToStorage(modelId, level);
return level;
}

// The active model can change WITHOUT a picker action: switching sessions,
// the snapshot adopting another session, or the catalog/default arriving
// late. Re-resolve the level for the new model so a pick made for one model
// is never submitted to — or rendered on — another (a foreign level used to
// leave the composer showing nothing selected with no way to switch). The
// picker path (setModel) applies the same resolution synchronously, so the
// watcher's re-resolution after it is an idempotent no-op.
watch(
() => currentModelId(),
(id, prevId) => {
if (id === undefined || id === prevId) return;
const model = modelById(id);
if (model === undefined) return;
rawState.thinking = thinkingLevelForModel(model);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve thinking for the session being submitted

When a user queues a follow-up in a session using model A and opens a session using model B before A finishes, this watcher replaces the sole rawState.thinking value with B's preference. The background completion path then drains A's queue through finishPromptLocal(sid)submitPromptInternal(sid), but useWorkspaceState.ts:1488-1494 combines A's model with that global value, so the original cross-model leak can recur and may submit an undeclared effort. Resolve thinking from the submitted session's model (or retain it with the queued prompt) rather than relying on the active-session value.

Useful? React with 👍 / 👎.

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 Apply restored levels before activating skills

When an existing session's model has a stored level that differs from that session's agent profile, this watcher updates only the picker state. activateSkill at useModelProviderState.ts:351-382 sends only the skill name and arguments, so the daemon runs with the session's stale profile effort even though the UI displays the restored per-model level. Ensure skill activation first applies the resolved level to the target session profile, as the new-session skill path already does.

Useful? React with 👍 / 👎.

},
);

/** 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
Expand Down Expand Up @@ -178,15 +239,13 @@ export function useModelProviderState(
try {
const api = getKimiWebApi();
models.value = await api.listModels();
// 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);
}
// Resolve the active model's level: the stored pick for THIS model when
// still declared, else the catalog default. In-memory only — localStorage
// stays reserved for levels the user actually picked. Always re-resolved
// (not just when unset) so a level carried over from another model can't
// outlive the catalog refresh that makes it invalid.
const active = modelById(currentModelId());
if (active !== undefined) rawState.thinking = thinkingLevelForModel(active);
} catch (err) {
pushOperationFailure('loadModels', err);
}
Expand Down Expand Up @@ -221,12 +280,22 @@ export function useModelProviderState(
? rawState.sessions.find((s) => s.id === sid)?.model
: undefined;
const isSwitch = currentModelId() !== (targetModel?.id ?? modelId);
const nextThinking = thinkingLevelForModelSwitch(targetModel, prevThinking, isSwitch);
// On a real switch, restore the target model's own stored pick when still
// declared; otherwise its catalog default (see thinkingLevelForModelSwitch).
const nextThinking = thinkingLevelForModelSwitch(
targetModel,
prevThinking,
isSwitch,
targetModel === undefined ? undefined : loadThinkingForModel(targetModel.id),
);
if (!sid) {
// New-session draft (onboarding composer): no backend session to update.
// Remember the pick — startSessionAndSendPrompt applies it at create time.
// In-memory only: a model switch is not a thinking pick, so nothing is
// persisted (a derived default would otherwise masquerade as an explicit
// choice later). Storage writes stay with setThinking.
draftModel.value = modelId;
applyThinkingLevel(nextThinking);
rawState.thinking = nextThinking;
if (nextThinking !== prevThinking && nextThinking !== undefined) {
persistGlobalThinking(nextThinking);
}
Expand All @@ -236,7 +305,7 @@ export function useModelProviderState(
// one so we can roll back if the switch never reaches the daemon.
updateSession(sid, (s) => ({ ...s, model: modelId }));
if (nextThinking !== prevThinking) {
applyThinkingLevel(nextThinking);
rawState.thinking = nextThinking;
}
try {
await getKimiWebApi().updateSession(sid, {
Expand All @@ -250,7 +319,7 @@ 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) {
applyThinkingLevel(prevThinking);
rawState.thinking = prevThinking;
}
pushOperationFailure('setModel', err, { sessionId: sid });
return false;
Expand Down Expand Up @@ -319,13 +388,31 @@ export function useModelProviderState(
}

try {
// Skill activation carries only name/args — the daemon runs the turn at
// the SESSION PROFILE effort. Persist the level resolved for this
// session's own model first (awaited, mirroring the new-session skill
// path), so a profile that predates the per-model restore can't run the
// skill at a stale effort while the UI shows the restored level. When
// the persist fails (it surfaces the error itself), activating would
// launch the skill at exactly that stale effort — abort instead.
// Session models can be '' transiently (daemon profile echo) — treat
// that as "unset" and resolve through the configured default, same as
// the prompt/BTW/steer paths, before selecting the thinking level.
const rawModel = rawState.sessions.find((s) => s.id === sid)?.model;
const skillModel = (rawModel && rawModel.length > 0 ? rawModel : rawState.defaultModel) ?? undefined;
const persisted = await persistSessionProfile(
{ thinking: thinkingLevelForModelId(skillModel) ?? rawState.thinking },
sid,
);
Comment on lines +403 to +406

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 Avoid persisting the new-session skill profile twice

When a skill is launched from the empty composer, startSessionAndActivateSkill already awaits persistSessionProfile with the resolved thinking level before calling activateSkill (useWorkspaceState.ts:1207-1220). This unconditional persist repeats the profile update and status refresh; if that redundant second update transiently fails, the sentinel path cancels the skill even though the prerequisite profile was successfully applied. Let the new-session path skip this second persist or consolidate profile persistence inside a single activation call.

Useful? React with 👍 / 👎.

if (!persisted) throw PROFILE_PERSIST_FAILED;
await getKimiWebApi().activateSkill(sid, skillName, args);
} catch (err) {
if (guarded) {
rawState.inFlightBySession = { ...rawState.inFlightBySession, [sid]: false };
updateSessionMessages(sid, (msgs) => msgs.filter((m) => m.id !== tempId));
}
pushOperationFailure('activateSkill', err, { sessionId: sid });
// The persist failure was already surfaced by persistSessionProfile.
if (err !== PROFILE_PERSIST_FAILED) pushOperationFailure('activateSkill', err, { sessionId: sid });
} finally {
// The daemon answered the activation (accepted or rejected) — the
// pending window in which a snapshot can't reflect this turn is over.
Expand Down Expand Up @@ -449,6 +536,7 @@ export function useModelProviderState(
loadModels,
loadProviders,
setModel,
thinkingLevelForModelId,
toggleStarModel,
activateSkill,
addProvider,
Expand Down
29 changes: 21 additions & 8 deletions apps/kimi-web/src/composables/client/useSideChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { computed, ref } from 'vue';
import { getKimiWebApi } from '../../api';
import type { AppMessage, KimiEventConnection } from '../../api/types';
import type { AppMessage, KimiEventConnection, ThinkingLevel } from '../../api/types';
import { messagesToTurns } from '../messagesToTurns';
import type { ChatTurn } from '../../types';
import type { ExtendedState } from '../useKimiWebClient';
Expand All @@ -23,10 +23,20 @@ export interface UseSideChatDeps {
nextOptimisticMsgId: () => string;
connectEventsIfNeeded: () => void;
getEventConn: () => KimiEventConnection | null;
/** Resolve the thinking level for an arbitrary model id (its stored pick
* when still declared, else its catalog default); undefined when the model
* is not in the catalog. */
thinkingLevelForModelId: (modelId: string | undefined) => ThinkingLevel | undefined;
}

export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
const { pushOperationFailure, nextOptimisticMsgId, connectEventsIfNeeded, getEventConn } = deps;
const {
pushOperationFailure,
nextOptimisticMsgId,
connectEventsIfNeeded,
getEventConn,
thinkingLevelForModelId,
} = deps;

const sideChatTargetBySession = ref<Record<string, { agentId: string }>>({});

Expand Down Expand Up @@ -198,11 +208,14 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
};
appendSideChatMessage(agentId, userMsg);
try {
// 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. Thinking goes verbatim
// (same as normal prompts and the TUI).
// Carry the parent's current model, thinking, 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. Thinking is resolved against the
// PARENT's own model (stored pick when declared, else its default) —
// never the active-session rawState.thinking: startBtw above may have
// spanned a session switch that changed what the active view resolved
// to (see submitPromptInternal in useWorkspaceState).
const promptSession = rawState.sessions.find((s) => s.id === sid);
const model =
(promptSession?.model && promptSession.model.length > 0
Expand All @@ -212,7 +225,7 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) {
content: [{ type: 'text', text: trimmed }],
agentId,
model,
thinking: rawState.thinking,
thinking: thinkingLevelForModelId(model) ?? rawState.thinking,
permissionMode: rawState.permission,
planMode: rawState.planModeBySession[sid] ?? false,
swarmMode: rawState.swarmModeBySession[sid] ?? false,
Expand Down
Loading
Loading