From 36e2ce2c78aba7edafba693daec71184b53c8de6 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 11 Oct 2026 18:13:14 +0800 Subject: [PATCH] refactor(codex): move field explanations into help tips The Codex edit page carried a grey explanation under almost every field. Following the v7 help rules, explanations of what a field is and when to use it now sit behind a "?" after the field name, and only consequences stay on the page: restart Codex after changing the model list, a max output above the real limit returns 400, User-Agent and request overrides only apply in routing mode, and Copilot needs routing in every mode. The collapsed advanced-section summary and the reasoning group note are gone; the Chat and Anthropic options already say they need routing. The shared endpoint, User-Agent and request override fields take an optional help text, so the other provider forms are unchanged. --- .../providers/forms/CodexFormFields.tsx | 287 +++++++++--------- .../providers/forms/CustomUserAgentField.tsx | 30 +- .../forms/LocalProxyRequestOverridesField.tsx | 30 +- .../providers/forms/shared/EndpointField.tsx | 15 +- src/i18n/locales/en.json | 42 +-- src/i18n/locales/ja.json | 42 +-- src/i18n/locales/zh-TW.json | 42 +-- src/i18n/locales/zh.json | 42 +-- .../CodexFormFields.subagent.test.tsx | 31 +- .../components/GrokBuildProviderForm.test.tsx | 15 +- 10 files changed, 314 insertions(+), 262 deletions(-) diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 21d1497da..234af6353 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { HelpTip } from "@/components/ui/help-tip"; @@ -1180,13 +1187,26 @@ export function CodexFormFields({ // 上游格式及 Anthropic 格式专有的几项:经典布局都在高级选项里;Stack 布局把上游格式和 // 认证字段放进连接区,其余留在高级选项里。 + // 字段名后面的「?」:规则和术语解释收进去;后果(要开路由、会报错)仍写在页面上。 + const labelWithHelp = (label: string, help: ReactNode, htmlFor?: string) => ( +
+ {label} + {help} +
+ ); + const upstreamFormatSelect = (
- - {t("codexConfig.upstreamFormatLabel", { - defaultValue: "上游格式", - })} - + {labelWithHelp( + t("codexConfig.upstreamFormatLabel", { defaultValue: "上游格式" }), + isCopilotPreset + ? t("codexConfig.upstreamFormatCopilotHint") + : t("codexConfig.upstreamFormatHint", { + defaultValue: + "看供应商原生支持哪种协议:Responses 直连、不转换格式;Chat 或 Anthropic 由路由转成 Responses。", + }), + "codex-upstream-format", + )} -

- {isCopilotPreset - ? t("codexConfig.upstreamFormatCopilotHint") - : t("codexConfig.upstreamFormatHint", { - defaultValue: - "供应商原生是 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。", - })} -

+ {/* Chat / Anthropic 的「需开启路由」已写在选项里;Copilot 的选项没写,单独提示。 */} + {isCopilotPreset && ( +

+ {t("codexConfig.upstreamFormatCopilotRouteHint")} +

+ )}
); const anthropicAuthFieldSelect = (
- - {t("codexConfig.anthropicAuthFieldLabel", { - defaultValue: "认证字段", - })} - + {labelWithHelp( + t("codexConfig.anthropicAuthFieldLabel", { defaultValue: "认证字段" }), + t("codexConfig.anthropicAuthFieldHint", { + defaultValue: "按网关读取 API Key 的请求头选择,两者只发其一。", + }), + "codex-anthropic-auth-field", + )} -

- {t("codexConfig.anthropicAuthFieldHint", { - defaultValue: - "选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。", - })} -

); const impersonateClaudeCodeToggle = (
-
- - {t("codexConfig.impersonateClaudeCodeLabel", { - defaultValue: "模拟 Claude Code 客户端", - })} - -

- {t("codexConfig.impersonateClaudeCodeHint", { - defaultValue: - "网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。", - })} -

-
+ {labelWithHelp( + t("codexConfig.impersonateClaudeCodeLabel", { + defaultValue: "模拟 Claude Code 客户端", + }), + t("codexConfig.impersonateClaudeCodeHint", { + defaultValue: + "网关只允许 Claude Code 使用时打开:伪装 Claude Code 的请求头,并在系统提示首行注入它的身份。", + }), + )} - - {t("codexConfig.maxOutputTokensLabel", { + {labelWithHelp( + t("codexConfig.maxOutputTokensLabel", { defaultValue: "最大输出 tokens", - })} - + }), + isGrokBuild + ? t("grokBuild.maxOutputTokensHint", { + defaultValue: + "默认上限 8192,长回答或深度思考容易被截断。这里的值会作为 Anthropic 的 max_tokens 发出。", + }) + : t("codexConfig.maxOutputTokensHint", { + defaultValue: + "Codex 不带输出上限,默认 8192,长回答或深度思考容易被截断。这里的值会作为 Anthropic 的 max_tokens 发出。", + }), + "codex-anthropic-max-output-tokens", + )}

- {isGrokBuild - ? t("grokBuild.maxOutputTokensHint", { - defaultValue: - "默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。", - }) - : t("codexConfig.maxOutputTokensHint", { - defaultValue: - "Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。", - })} + {t("codexConfig.maxOutputTokensWarning", { + defaultValue: "不要超过模型或网关的真实输出上限,否则会返回 400。", + })}

); @@ -1348,11 +1362,15 @@ export function CodexFormFields({ const reasoningFields = ( <>
- - {t("codexConfig.promptCacheRoutingLabel", { + {labelWithHelp( + t("codexConfig.promptCacheRoutingLabel", { defaultValue: "提示词缓存路由", - })} - + }), + t("codexConfig.promptCacheRoutingHint", { + defaultValue: + "自动:只对确认兼容的上游发送 prompt_cache_key。开启:用于其他兼容网关。关闭:避免严格网关因未知字段返回 400。", + }), + )} -

- {t("codexConfig.promptCacheRoutingHint", { - defaultValue: - "自动模式仅对已确认兼容的上游发送 prompt_cache_key;开启可用于其他兼容网关,关闭可避免严格网关因未知字段返回 400。只使用客户端提供的稳定会话 ID。", - })} -

-
- - {t("codexConfig.reasoningGroupTitle", { - defaultValue: "思考能力", - })} - -

- {t("codexConfig.reasoningSectionHint", { - defaultValue: - "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。", - })} -

-
+ + {t("codexConfig.reasoningGroupTitle", { + defaultValue: "思考能力", + })} +
-
- - {t("codexConfig.reasoningModeToggle", { - defaultValue: "支持思考模式", - })} - -

- {t("codexConfig.reasoningModeHint", { - defaultValue: - "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。", - })} -

-
+ {labelWithHelp( + t("codexConfig.reasoningModeToggle", { + defaultValue: "支持思考模式", + }), + t("codexConfig.reasoningModeHint", { + defaultValue: + "上游 Chat 接口能开关 thinking 时打开,Kimi、GLM、Qwen 等通常属于这一类。", + }), + )}
-
- - {t("codexConfig.reasoningEffortToggle", { - defaultValue: "支持思考等级", - })} - -

- {isGrokBuild - ? t("grokBuild.reasoningEffortHint", { - defaultValue: - "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把请求中的 reasoning effort 转成上游 Chat 参数。", - }) - : t("codexConfig.reasoningEffortHint", { - defaultValue: - "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。", - })} -

-
+ {labelWithHelp( + t("codexConfig.reasoningEffortToggle", { + defaultValue: "支持思考等级", + }), + isGrokBuild + ? t("grokBuild.reasoningEffortHint", { + defaultValue: + "上游支持 low/high/max 等思考深度时打开。会同时打开思考模式,并把请求中的 reasoning effort 转成上游 Chat 参数。", + }) + : t("codexConfig.reasoningEffortHint", { + defaultValue: + "上游支持 low/high/max 等思考深度时打开。会同时打开思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。", + }), + )} ); + // 两项都只在路由模式下生效:在分组开头说一次,各自的作用收进字段名后的「?」。 const userAgentAndOverrides = ( <> +

+ {t("codexConfig.routeOnlySettingsHint", { + defaultValue: "以下两项只在路由模式下生效。", + })} +

@@ -1681,7 +1689,7 @@ export function CodexFormFields({ value={codexBaseUrl} onChange={onBaseUrlChange} placeholder={t("providerForm.codexApiEndpointPlaceholder")} - hint={t("providerForm.codexApiHint")} + help={t("providerForm.codexApiHint")} showFullUrlToggle isFullUrl={isFullUrl} onFullUrlChange={onFullUrlChange} @@ -1883,13 +1891,13 @@ export function CodexFormFields({ })} {t("codexConfig.modelListDefaultHelp", { defaultValue: - "第一行是默认模型,Codex 启动时用它。列表为空时,可以在下方单独填写默认模型。", + "这些模型会出现在 Codex 的 /model 里,带上各自的上下文窗口和思考等级。第一行是默认模型。", })}
@@ -1902,8 +1910,7 @@ export function CodexFormFields({

{t("codexConfig.modelListHint", { - defaultValue: - "这些模型会出现在 Codex 的 /model 里,带上各自的上下文窗口和思考等级。修改后需要重启 Codex。", + defaultValue: "修改后需要重启 Codex。", })}

@@ -1926,9 +1933,18 @@ export function CodexFormFields({ onModelChange && (!firstRowIsDefault || catalogRows.length === 0) && (
- - {t("codexConfig.defaultModelLabel", { defaultValue: "默认模型" })} - + {labelWithHelp( + t("codexConfig.defaultModelLabel", { defaultValue: "默认模型" }), + isGrokBuild + ? t("grokBuild.defaultModelHint", { + defaultValue: "Grok Build 默认请求的模型,随时可改。", + }) + : t("codexConfig.defaultModelHint", { + defaultValue: + "Codex 默认请求的模型。添加模型列表后,列表第一行就是默认模型。", + }), + "codexDefaultModel", + )}
)}
-

- {isGrokBuild - ? t("grokBuild.defaultModelHint", { - defaultValue: - "Grok Build 默认请求的模型,随时可改,无需等待预设更新。", - }) - : t("codexConfig.defaultModelHint", { - defaultValue: - "Codex 默认请求的模型,随时可改,无需等待预设更新。添加模型列表后,列表第一行就是默认模型。", - })} -

)} @@ -1990,11 +1995,16 @@ export function CodexFormFields({ 留空删键,子代理跟随主模型;聚合模式下不写进 live。 */} {category !== "official" && onSubagentModelChange && (
- - {t("codexConfig.subagentModelLabel", { + {labelWithHelp( + t("codexConfig.subagentModelLabel", { defaultValue: "子代理模型", - })} - + }), + t("codexConfig.subagentModelHint", { + defaultValue: + "Codex 派出的子代理默认用的模型和推理强度。留空时沿用主代理的;只填模型时用该模型自己的默认强度。聚合模式下不生效,子代理跟随主模型。", + }), + "codexSubagentModel", + )}
)}
-

- {t("codexConfig.subagentModelHint", { - defaultValue: - "Codex 派出的子代理默认用的模型和推理强度。留空时子代理沿用主代理的;只填模型时用该模型自己的默认强度。聚合模式下不生效,子代理跟随主模型。", - })} -

{isSubagentModelOutsideCatalog && (

{t("codexConfig.subagentModelNotInCatalog", { @@ -2105,19 +2109,6 @@ export function CodexFormFields({ })} - {!advancedExpanded && ( -

- {isGrokBuild - ? t("grokBuild.advancedSectionHint", { - defaultValue: - "包含上游格式、思考能力与自定义 User-Agent。使用 Chat Completions / Anthropic Messages 协议的供应商需开启路由接管才能使用。", - }) - : t("codexConfig.advancedSectionHint", { - defaultValue: - "包含上游格式、思考能力与自定义 User-Agent。使用 Chat Completions / Anthropic Messages 协议的供应商需开启路由才能使用。", - })} -

- )} {/* Copilot supports automatic capability routing or an explicit protocol; other providers retain their existing format controls. */} diff --git a/src/components/providers/forms/CustomUserAgentField.tsx b/src/components/providers/forms/CustomUserAgentField.tsx index 91a178c96..7d9284854 100644 --- a/src/components/providers/forms/CustomUserAgentField.tsx +++ b/src/components/providers/forms/CustomUserAgentField.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from "lucide-react"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { HelpTip } from "@/components/ui/help-tip"; import { DropdownMenu, DropdownMenuContent, @@ -17,6 +18,8 @@ interface CustomUserAgentFieldProps { id: string; value: string; onChange: (value: string) => void; + /** 传了就把说明放进字段名后的「?」,替代下方的「仅在路由下生效」提示。 */ + help?: string; } /** @@ -30,17 +33,20 @@ export function CustomUserAgentField({ id, value, onChange, + help, }: CustomUserAgentFieldProps) { const { t } = useTranslation(); const valid = isValidUserAgentHeader(value); + const label = t("providerForm.customUserAgent", { + defaultValue: "自定义 User-Agent", + }); return (
- - {t("providerForm.customUserAgent", { - defaultValue: "自定义 User-Agent", - })} - +
+ {label} + {help ? {help} : null} +
{valid ? ( -

- {t("providerForm.customUserAgentHint", { - defaultValue: - "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", - })} -

+ !help && ( +

+ {t("providerForm.customUserAgentHint", { + defaultValue: + "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。", + })} +

+ ) ) : (

{t("providerForm.customUserAgentInvalid", { diff --git a/src/components/providers/forms/LocalProxyRequestOverridesField.tsx b/src/components/providers/forms/LocalProxyRequestOverridesField.tsx index 79265dc38..cdc7fcb04 100644 --- a/src/components/providers/forms/LocalProxyRequestOverridesField.tsx +++ b/src/components/providers/forms/LocalProxyRequestOverridesField.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Textarea } from "@/components/ui/textarea"; +import { HelpTip } from "@/components/ui/help-tip"; import { parseBodyOverrideJson, parseHeaderOverrideJson, @@ -11,6 +12,8 @@ interface LocalProxyRequestOverridesFieldProps { bodyJson: string; onHeadersJsonChange: (value: string) => void; onBodyJsonChange: (value: string) => void; + /** 传了就把说明放进字段名后的「?」,替代下方的「仅在路由下生效」提示。 */ + help?: string; } export function LocalProxyRequestOverridesField({ @@ -18,25 +21,30 @@ export function LocalProxyRequestOverridesField({ bodyJson, onHeadersJsonChange, onBodyJsonChange, + help, }: LocalProxyRequestOverridesFieldProps) { const { t } = useTranslation(); + const label = t("providerForm.localProxyRequestOverrides", { + defaultValue: "本地代理请求覆盖", + }); const headerError = parseHeaderOverrideJson(headersJson).error; const bodyError = parseBodyOverrideJson(bodyJson).error; return (

- - {t("providerForm.localProxyRequestOverrides", { - defaultValue: "本地代理请求覆盖", - })} - -

- {t("providerForm.localProxyRequestOverridesHint", { - defaultValue: - "仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。", - })} -

+
+ {label} + {help ? {help} : null} +
+ {!help && ( +

+ {t("providerForm.localProxyRequestOverridesHint", { + defaultValue: + "仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。", + })} +

+ )}
diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index bc9656993..dfcfb7be4 100644 --- a/src/components/providers/forms/shared/EndpointField.tsx +++ b/src/components/providers/forms/shared/EndpointField.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { HelpTip } from "@/components/ui/help-tip"; import { Link2, Zap } from "lucide-react"; import { REQUIRED_LABEL } from "../BasicFormFields"; @@ -12,6 +13,8 @@ interface EndpointFieldProps { onChange: (value: string) => void; placeholder: string; hint?: string; + /** 填写说明放进字段名后的「?」,不占页面;和 `hint` 二选一。完整 URL 的提示照常显示。 */ + help?: string; fullUrlHint?: string; showManageButton?: boolean; onManageClick?: () => void; @@ -28,6 +31,7 @@ export function EndpointField({ onChange, placeholder, hint, + help, fullUrlHint, showManageButton = true, onManageClick, @@ -54,9 +58,14 @@ export function EndpointField({
- - {label} - +
+ + {label} + + {help ? ( + {help.replace(/^💡\s*/u, "")} + ) : null} +
{showFullUrlToggle && onFullUrlChange ? (
{ }); expect(screen.getByText("模型列表")).toBeVisible(); - expect(screen.getByRole("button", { name: "默认模型" })).toBeVisible(); + // 「第一行是默认模型」写在「模型列表」后面的「?」里。 + expect(screen.getByRole("button", { name: "模型列表" })).toBeVisible(); expect(screen.queryByText("上游格式")).toBeNull(); expect(document.getElementById("codexDefaultModel")).toBeNull(); expect( @@ -170,3 +171,31 @@ describe("CodexFormFields direct layout", () => { ).toBeInTheDocument(); }); }); + +describe("CodexFormFields help tips", () => { + it("puts field explanations behind「?」and keeps consequences on the page", () => { + renderCodexForm({ + apiFormat: "anthropic", + // 有地址栏的卡才显示上游格式。 + shouldShowSpeedTest: true, + catalogModels: [], + onCatalogModelsChange: vi.fn(), + }); + + for (const name of [ + "codexConfig.apiUrlLabel", + "默认模型", + "子代理模型", + ]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + } + // Anthropic 格式的高级区自动展开:后果提示仍在页面上,说明在字段名后的「?」里。 + for (const name of ["上游格式", "认证字段", "最大输出 tokens"]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + } + expect( + screen.getByText("不要超过模型或网关的真实输出上限,否则会返回 400。"), + ).toBeVisible(); + expect(screen.getByText("以下两项只在路由模式下生效。")).toBeVisible(); + }); +}); diff --git a/tests/components/GrokBuildProviderForm.test.tsx b/tests/components/GrokBuildProviderForm.test.tsx index 667944f81..7a10e8a5f 100644 --- a/tests/components/GrokBuildProviderForm.test.tsx +++ b/tests/components/GrokBuildProviderForm.test.tsx @@ -280,11 +280,8 @@ context_window = 500000 // Grok 没有模型映射目录,不应出现"映射第一行"条款 expect(screen.queryByText(/映射第一行/)).toBeNull(); - // Chat 格式且无已配置高级值时高级区默认折叠,折叠提示也应是 Grok 版本 - expect( - screen.getByText(/Anthropic Messages 协议的供应商需开启路由接管/), - ).toBeInTheDocument(); - expect(screen.queryByText(/模型映射、思考能力/)).toBeNull(); + // Chat 格式且无已配置高级值时高级区默认折叠,折叠时不再挂说明小字 + expect(screen.queryByText(/思考能力与自定义 User-Agent/)).toBeNull(); await user.click(screen.getByRole("button", { name: /高级选项/ })); expect( @@ -320,9 +317,11 @@ context_window = 500000 />, ); - expect(screen.getByText(/^默认上限 8192 容易在长回答/)).toBeInTheDocument(); + // 说明在「?」里(读屏副本一直在页面上),页面上只留超限会 400 的提醒。 + expect(screen.getByText(/^默认上限 8192,长回答/)).toBeInTheDocument(); + expect(screen.queryByText(/^Codex 不带输出上限/)).toBeNull(); expect( - screen.queryByText(/Codex 不会把 model_max_output_tokens/), - ).toBeNull(); + screen.getByText("不要超过模型或网关的真实输出上限,否则会返回 400。"), + ).toBeInTheDocument(); }); });