-
Notifications
You must be signed in to change notification settings - Fork 493
fix(web): remember the thinking level per model #1838
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
26c08b9
2251edd
9b6ab4d
084cbdd
198b250
17a3550
0b844e7
818bfb5
94383b3
2175508
608ad96
1837681
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -19,6 +19,7 @@ import type { | |
| import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage'; | ||
| import { | ||
| defaultThinkingLevelFor, | ||
| levelDeclaredBy, | ||
| thinkingLevelForModelSwitch, | ||
| thinkingLevelToConfig, | ||
| } from '../../lib/modelThinking'; | ||
|
|
@@ -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); | ||
|
|
@@ -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; | ||
|
|
@@ -89,6 +103,7 @@ export function useModelProviderState( | |
| refreshSessionStatus, | ||
| persistSessionProfile, | ||
| activity, | ||
| loadThinkingForModel, | ||
| saveThinkingToStorage, | ||
| updateSession, | ||
| updateSessionMessages, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When another tab changes the thinking level for the same model, this resolver sees the new localStorage map on every submission, but 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. 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 | ||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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, { | ||
|
|
@@ -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; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a skill is launched from the empty composer, 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. | ||
|
|
@@ -449,6 +536,7 @@ export function useModelProviderState( | |
| loadModels, | ||
| loadProviders, | ||
| setModel, | ||
| thinkingLevelForModelId, | ||
| toggleStarModel, | ||
| activateSkill, | ||
| addProvider, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When localStorage is unavailable or
setItemthrows—for example because of a storage policy or quota—the safe storage helpers swallow the failure, sosetThinkingupdates onlyrawState.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 👍 / 👎.