feat(web): drop "none" from the offered thinking levels (#34)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ Some models in the preset catalog: deepseek-v4-pro / deepseek-v4-flash, gemini-3
|
||||
|
||||
## Thinking levels
|
||||
|
||||
Five levels: `none | low | medium | high | xhigh`, configured per Agent as `model.thinking_level` in `system_config.yaml`, default medium. The chat draft view offers a quick picker next to the model selector: a picked level is written back to the selected Agent's setting immediately (the switched-to level becomes that Agent's new default and applies from the next session; a running session keeps the level it was created with). See [Configuration](/configuration).
|
||||
Five levels: `none | low | medium | high | xhigh`, configured per Agent as `model.thinking_level` in `system_config.yaml`, default medium. The Web pickers offer `low` and above only (many models cannot disable thinking; `none` stays a valid stored value and still displays). The chat draft view offers a quick picker next to the model selector: a picked level is written back to the selected Agent's setting immediately (the switched-to level becomes that Agent's new default and applies from the next session; a running session keeps the level it was created with). See [Configuration](/configuration).
|
||||
|
||||
## Models decoupled from Agents
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ api_key = "sk-..."
|
||||
|
||||
## 思考等级
|
||||
|
||||
思考等级共五档:`none | low | medium | high | xhigh`,按 Agent 在 `system_config.yaml` 的 `model.thinking_level` 配置,默认 medium。对话草稿页在模型选择器旁提供快捷拾取器:选定档位立即写回所选 Agent 的该项配置(切换后的档位即成为该 Agent 的新默认,自下一个 Session 生效;进行中的 Session 沿用创建时的档位)。见 [配置参考](/configuration)。
|
||||
思考等级共五档:`none | low | medium | high | xhigh`,按 Agent 在 `system_config.yaml` 的 `model.thinking_level` 配置,默认 medium。Web 拾取器只提供 `low` 及以上档位(多数模型不支持关闭思考;`none` 仍是合法的已存值,能正常显示)。对话草稿页在模型选择器旁提供快捷拾取器:选定档位立即写回所选 Agent 的该项配置(切换后的档位即成为该 Agent 的新默认,自下一个 Session 生效;进行中的 Session 沿用创建时的档位)。见 [配置参考](/configuration)。
|
||||
|
||||
## 模型与 Agent 解耦
|
||||
|
||||
|
||||
@@ -87,14 +87,16 @@ test("draft: pick model/approval -> reload restores them -> send creates the ses
|
||||
await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");
|
||||
|
||||
// Conversation-time thinking level (backed by the Agent settings): the picker shows the
|
||||
// seeded default (medium, short name 中); the menu carries a title bar and exactly the five
|
||||
// short-name rows (no descriptions, no default row); picking 高 writes straight through to
|
||||
// the Agent config, so the session created on send runs with it and it becomes the Agent's
|
||||
// new default.
|
||||
// seeded default (medium, short name 中); the menu carries a title bar and the short-name
|
||||
// rows 低/中/高/极高 only — no descriptions, no default row, and no 无 (many models cannot
|
||||
// disable thinking); picking 高 writes straight through to the Agent config, so the session
|
||||
// created on send runs with it and it becomes the Agent's new default.
|
||||
const thinkingBtn = page.getByRole("button", { name: "思考等级" });
|
||||
await expect(thinkingBtn).toContainText("中");
|
||||
await thinkingBtn.click();
|
||||
await expect(page.getByText("思考等级", { exact: true })).toBeVisible(); // menu title bar
|
||||
await expect(page.getByRole("button", { name: "低", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "无", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "高", exact: true }).click();
|
||||
await expect(thinkingBtn).toContainText("高");
|
||||
await expect
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Modal } from "../../components/ui/modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
import { SchedulesTab } from "./schedules-tab";
|
||||
import { thinkingLevelOptionsFor } from "../chat/thinking-level";
|
||||
|
||||
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
||||
|
||||
@@ -500,7 +501,16 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
};
|
||||
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
const thinkingLevelOptions = withDefaultOption(S.agent.thinkingLevelOptions);
|
||||
// "none" is no longer offered (many models cannot disable thinking) but stays a valid stored
|
||||
// value: assembly lives in thinkingLevelOptionsFor, gated on the **persisted** config — a
|
||||
// misclick onto another tier keeps the legacy row until the change is actually saved, so the
|
||||
// stored value stays reachable (see thinking-level.ts).
|
||||
const thinkingLevelOptions = thinkingLevelOptionsFor(
|
||||
S.agent.thinkingLevelOptions,
|
||||
S.agent.defaultValue,
|
||||
S.agent.thinkingLevelNoneKept,
|
||||
cfg.model?.thinkingLevel,
|
||||
);
|
||||
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
|
||||
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
||||
import { matchSlash, removeSlashToken } from "./slash-token";
|
||||
import { THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level";
|
||||
import { SELECTABLE_THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level";
|
||||
import {
|
||||
BOOK_ICON,
|
||||
buildSkillsMessage,
|
||||
@@ -393,8 +393,10 @@ const SPARK_ICON = "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12
|
||||
* level straight through to the Agent settings — llmConfig is assembled once per session, so
|
||||
* the level applies to the session created on first send and becomes the Agent's new default
|
||||
* (switch-becomes-default). Per review: a title bar names the control, and the menu lists
|
||||
* exactly the five levels with short names only (no descriptions, no "default" row) — an
|
||||
* Agent without an explicit override shows an em dash until a level is picked.
|
||||
* the selectable levels with short names only (no descriptions, no "default" row, and no
|
||||
* "none" — many models cannot disable thinking); an Agent without an explicit override shows
|
||||
* an em dash until a level is picked, and a stored legacy "none" still displays via the
|
||||
* label table (just never offered).
|
||||
*/
|
||||
function ThinkingLevelSelect({
|
||||
value,
|
||||
@@ -449,7 +451,7 @@ function ThinkingLevelSelect({
|
||||
<div className="border-b border-gray-100 px-3 pb-1.5 pt-0.5 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||||
{S.chat.thinkingLevel}
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{SELECTABLE_THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
|
||||
@@ -5,18 +5,29 @@
|
||||
* it shows the selected Agent's current level and writes a picked level straight through
|
||||
* to the Agent config, so the session created on first send — which reads systemConfig
|
||||
* fresh — runs with it, and it becomes the Agent's new default (switch-becomes-default).
|
||||
* Per review: the menu lists exactly the five levels with short names only (no
|
||||
* descriptions, no "default" row) under a title bar naming the control.
|
||||
* Per review: the menu lists the levels with short names only (no descriptions, no
|
||||
* "default" row) under a title bar naming the control — and it does **not** offer "none"
|
||||
* (many models cannot disable thinking): "none" stays a valid stored/wire value, so a
|
||||
* legacy config or session that carries it still displays via the label table below.
|
||||
*/
|
||||
|
||||
/** The five levels, in menu order (mirrors core's ThinkingLevelName). */
|
||||
/** All five stored/wire levels, for display lookup (mirrors core's ThinkingLevelName). */
|
||||
export const THINKING_LEVELS = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
|
||||
/**
|
||||
* The levels the picker offers, in menu order: "none" is deliberately excluded (many models
|
||||
* don't support disabling thinking) — it can still be displayed (a stored legacy value) but
|
||||
* never picked.
|
||||
*/
|
||||
export const SELECTABLE_THINKING_LEVELS = ["low", "medium", "high", "xhigh"] as const;
|
||||
|
||||
/**
|
||||
* Short display label for a level from the localized name table (S.chat.thinkingLevelNames).
|
||||
* Returns null for anything outside the five levels — including "" (an Agent without an
|
||||
* explicit override) and session_meta's "default" — so callers can render a placeholder on
|
||||
* the trigger and hide the session read-only tag instead of showing a raw internal value.
|
||||
* Covers all five stored levels including "none" (so a legacy value displays sanely instead
|
||||
* of being rewritten or hidden). Returns null for anything else — including "" (an Agent
|
||||
* without an explicit override) and session_meta's "default" — so callers can render a
|
||||
* placeholder on the trigger and hide the session read-only tag instead of showing a raw
|
||||
* internal value.
|
||||
*/
|
||||
export function thinkingLevelLabel(
|
||||
names: Readonly<Record<string, string>>,
|
||||
@@ -26,3 +37,46 @@ export function thinkingLevelLabel(
|
||||
? (names[level] ?? level)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** One dropdown row of the agent-settings thinking-level menu (shape of OptionMenuChoice<string>). */
|
||||
export interface ThinkingLevelOptionRow {
|
||||
value: string;
|
||||
/** Compact text on the trigger button ("" renders the localized default tag). */
|
||||
triggerLabel: string;
|
||||
/** Panel row title. */
|
||||
label: string;
|
||||
/** Panel row description. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the agent-settings thinking-level dropdown rows from the dictionary's
|
||||
* [value, description] pairs (which no longer include `none`). Backward compatibility:
|
||||
* when the **persisted** config already stores `none` (a legacy value), a display-only
|
||||
* row for it is appended — the trigger shows the real stored state instead of "—" and
|
||||
* nothing is silently rewritten. Gating on the persisted value (not the local edit
|
||||
* state) means a misclick onto another tier keeps the row until the change is actually
|
||||
* saved, so the user can always click back to the value still stored on disk.
|
||||
*/
|
||||
export function thinkingLevelOptionsFor(
|
||||
options: ReadonlyArray<readonly [string, string]>,
|
||||
defaultTag: string,
|
||||
noneKeptDescription: string,
|
||||
storedLevel: string | undefined,
|
||||
): ThinkingLevelOptionRow[] {
|
||||
const rows = options.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value || defaultTag,
|
||||
label: value || defaultTag,
|
||||
description,
|
||||
}));
|
||||
if (storedLevel === "none") {
|
||||
rows.push({
|
||||
value: "none",
|
||||
triggerLabel: "none",
|
||||
label: "none",
|
||||
description: noneKeptDescription,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,6 @@ export const en: Strings = {
|
||||
thinkingLevel: "model.thinking_level",
|
||||
thinkingLevelOptions: [
|
||||
["", "Send no override — keep whatever is currently configured."],
|
||||
["none", "Disables extended reasoning; the fastest responses."],
|
||||
["low", "Enables a lower tier of extended reasoning."],
|
||||
[
|
||||
"medium",
|
||||
@@ -209,6 +208,8 @@ export const en: Strings = {
|
||||
"Enables the highest tier of extended reasoning; identical to high on some models.",
|
||||
],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
thinkingLevelNoneKept:
|
||||
"Stored legacy tier: new selections no longer offer the off tier (many models cannot disable thinking).",
|
||||
timeoutMs: "model.timeoutMs",
|
||||
timeoutMsHint: "Per-request timeout, ms",
|
||||
compaction: "Context compaction",
|
||||
|
||||
@@ -188,14 +188,16 @@ export const zh = {
|
||||
maxTurns: "max_turns(单 Task 最大轮次,-1 不限制)",
|
||||
maxTokens: "model.max_tokens",
|
||||
thinkingLevel: "model.thinking_level",
|
||||
/** Selectable tiers exclude `none` (many models cannot disable thinking); a stored `none` still displays — see `thinkingLevelNoneKept`. */
|
||||
thinkingLevelOptions: [
|
||||
["", "不提交覆盖值,沿用当前生效的配置。"],
|
||||
["none", "关闭扩展推理,响应最快。"],
|
||||
["low", "开启较低强度的扩展推理。"],
|
||||
["medium", "开启中等强度的扩展推理(新建 Agent 的缺省档位)。"],
|
||||
["high", "开启较高强度的扩展推理,响应更慢。"],
|
||||
["xhigh", "开启最高强度的扩展推理,部分模型上效果与 high 相同。"],
|
||||
] as ReadonlyArray<readonly [string, string]>,
|
||||
/** Row description shown only while the stored config is `none`: displayed as-is, never rewritten, and no longer offered as a choice. */
|
||||
thinkingLevelNoneKept: "已存的历史档位:新选择不再提供关闭档(多数模型不支持关闭思考)。",
|
||||
timeoutMs: "model.timeoutMs",
|
||||
timeoutMsHint: "单次 Request 超时,毫秒",
|
||||
compaction: "上下文压缩(compaction)",
|
||||
@@ -463,7 +465,7 @@ export const zh = {
|
||||
chooseAgent: "选择 Agent",
|
||||
chooseModel: "选择模型",
|
||||
thinkingLevel: "思考等级",
|
||||
/** 会话前拾取器的档位短名(评审要求:只写短名、不带说明、无“缺省”项)。 */
|
||||
/** Short tier names for the pre-conversation picker (per review: short names only, no descriptions, no "default" row). `none` exists purely to display a stored legacy value — it is never offered as a choice (many models cannot disable thinking). */
|
||||
thinkingLevelNames: {
|
||||
none: "无",
|
||||
low: "低",
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* thinking-level.ts unit tests: the conversation-time picker's short-name lookup — the five
|
||||
* levels map to their localized names, while "" (no override yet) and session_meta's
|
||||
* "default" resolve to null (trigger shows a placeholder; the session tag hides).
|
||||
* thinking-level.ts unit tests: the conversation-time picker's short-name lookup and the
|
||||
* selectable list — the picker offers only low/medium/high/xhigh (many models cannot disable
|
||||
* thinking), while "none" stays a displayable stored value; "" (no override yet) and
|
||||
* session_meta's "default" resolve to null (trigger shows a placeholder; the session tag hides).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { THINKING_LEVELS, thinkingLevelLabel } from "../src/features/chat/thinking-level";
|
||||
import {
|
||||
SELECTABLE_THINKING_LEVELS,
|
||||
THINKING_LEVELS,
|
||||
thinkingLevelLabel,
|
||||
thinkingLevelOptionsFor,
|
||||
} from "../src/features/chat/thinking-level";
|
||||
|
||||
/** Mirrors the shape of S.chat.thinkingLevelNames. */
|
||||
const NAMES: Readonly<Record<string, string>> = {
|
||||
@@ -15,8 +21,13 @@ const NAMES: Readonly<Record<string, string>> = {
|
||||
xhigh: "Extreme High",
|
||||
};
|
||||
|
||||
describe("thinkingLevelLabel", () => {
|
||||
it("maps each of the five levels to its localized short name (menu order preserved)", () => {
|
||||
describe("thinking level lists", () => {
|
||||
it("offers only low/medium/high/xhigh — no 'none' (many models cannot disable thinking)", () => {
|
||||
expect(SELECTABLE_THINKING_LEVELS).toEqual(["low", "medium", "high", "xhigh"]);
|
||||
expect(SELECTABLE_THINKING_LEVELS).not.toContain("none");
|
||||
});
|
||||
|
||||
it("keeps all five stored levels displayable (a legacy 'none' is shown, never offered)", () => {
|
||||
expect(THINKING_LEVELS).toEqual(["none", "low", "medium", "high", "xhigh"]);
|
||||
expect(THINKING_LEVELS.map((l) => thinkingLevelLabel(NAMES, l))).toEqual([
|
||||
"None",
|
||||
@@ -26,7 +37,9 @@ describe("thinkingLevelLabel", () => {
|
||||
"Extreme High",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingLevelLabel", () => {
|
||||
it("returns null for non-levels: '' (no override yet), session_meta's 'default', unknown, null", () => {
|
||||
expect(thinkingLevelLabel(NAMES, "")).toBeNull();
|
||||
expect(thinkingLevelLabel(NAMES, "default")).toBeNull();
|
||||
@@ -39,3 +52,34 @@ describe("thinkingLevelLabel", () => {
|
||||
expect(thinkingLevelLabel({}, "medium")).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingLevelOptionsFor (agent-settings dropdown assembly)", () => {
|
||||
/** Mirrors the shape of S.agent.thinkingLevelOptions after the none row's removal. */
|
||||
const OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
||||
["", "Send no override."],
|
||||
["low", "Low tier."],
|
||||
["medium", "Medium tier."],
|
||||
["high", "High tier."],
|
||||
["xhigh", "Extra-high tier."],
|
||||
];
|
||||
|
||||
it("maps the dictionary in order; the '' row renders the default tag; no none row normally", () => {
|
||||
for (const stored of [undefined, "", "medium", "xhigh"]) {
|
||||
const rows = thinkingLevelOptionsFor(OPTIONS, "(default)", "legacy none", stored);
|
||||
expect(rows.map((r) => r.value)).toEqual(["", "low", "medium", "high", "xhigh"]);
|
||||
expect(rows[0]).toMatchObject({ triggerLabel: "(default)", label: "(default)" });
|
||||
expect(rows.some((r) => r.value === "none")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("appends a display-only none row when the persisted config stores none (backward compat)", () => {
|
||||
const rows = thinkingLevelOptionsFor(OPTIONS, "(default)", "legacy none", "none");
|
||||
expect(rows.map((r) => r.value)).toEqual(["", "low", "medium", "high", "xhigh", "none"]);
|
||||
expect(rows.at(-1)).toEqual({
|
||||
value: "none",
|
||||
triggerLabel: "none",
|
||||
label: "none",
|
||||
description: "legacy none",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user