feat(provider-form): custom User-Agent presets dropdown in advanced settings

Polish the provider-level User-Agent override UI on the Claude and Codex forms.

- Add a shared CustomUserAgentField (label + input + preset dropdown + live
  validation) so both forms stay in sync.
- Provide curated UA presets (Claude Code / Kilo Code families that pass
  coding-plan UA whitelists per #3671); the first is Claude Code's real
  `claude-cli/x (external, cli)` format. Whitelists gate on the name prefix,
  not the version, so static values stay valid across upgrades.
- Expose presets via a dropdown to the right of the input (z-[200] so it
  renders above the dialog layers) instead of inline chips.
- Move the field into the existing advanced/reasoning collapsibles.
- userAgent.ts mirrors the backend byte rule (reject only control chars;
  non-ASCII is allowed) for a non-blocking inline hint.
- i18n for all four locales (zh/en/ja/zh-TW).
This commit is contained in:
Jason
2026-06-10 16:39:26 +08:00
parent 8b925c2f2f
commit 596019505f
11 changed files with 212 additions and 56 deletions
@@ -47,6 +47,7 @@ import {
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { CustomUserAgentField } from "./CustomUserAgentField";
import type {
ProviderCategory,
ClaudeApiFormat,
@@ -204,7 +205,8 @@ export function ClaudeFormFields({
defaultSonnetModel ||
defaultOpusModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
customUserAgent
);
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
@@ -257,7 +259,7 @@ export function ClaudeFormFields({
const modelsUrl = matchedPreset?.modelsUrl;
setIsFetchingModels(true);
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl)
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl, customUserAgent)
.then((models) => {
setFetchedModels(models);
showModelFetchResult(models.length);
@@ -267,7 +269,7 @@ export function ClaudeFormFields({
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]);
}, [baseUrl, apiKey, isFullUrl, customUserAgent, showModelFetchResult, t]);
const handleFetchCopilotModels = useCallback(() => {
if (!isCopilotAuthenticated) {
@@ -671,30 +673,6 @@ export function ClaudeFormFields({
/>
)}
{category !== "official" && (
<div className="space-y-2">
<FormLabel htmlFor="claude-custom-user-agent">
{t("providerForm.customUserAgent", {
defaultValue: "自定义 User-Agent",
})}
</FormLabel>
<Input
id="claude-custom-user-agent"
type="text"
value={customUserAgent}
onChange={(e) => onCustomUserAgentChange(e.target.value)}
placeholder="Mozilla/5.0 ..."
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t("providerForm.customUserAgentHint", {
defaultValue:
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
})}
</p>
</div>
)}
{shouldShowModelSelector && (
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
<CollapsibleTrigger asChild>
@@ -962,6 +940,12 @@ export function ClaudeFormFields({
})}
</p>
</div>
<CustomUserAgentField
id="claude-custom-user-agent"
value={customUserAgent}
onChange={onCustomUserAgentChange}
/>
</CollapsibleContent>
</Collapsible>
)}
@@ -25,6 +25,7 @@ import {
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { CustomUserAgentField } from "./CustomUserAgentField";
import type {
CodexApiFormat,
CodexCatalogModel,
@@ -222,7 +223,13 @@ export function CodexFormFields({
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl)
fetchModelsForConfig(
codexBaseUrl,
codexApiKey,
isFullUrl,
undefined,
customUserAgent,
)
.then((models) => {
setFetchedModels(models);
if (models.length === 0) {
@@ -238,7 +245,7 @@ export function CodexFormFields({
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
}, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]);
const handleAddCatalogRow = useCallback(() => {
if (!onCatalogModelsChange) return;
@@ -436,6 +443,14 @@ export function CodexFormFields({
})}
/>
</div>
<div className="border-t border-border-default pt-3">
<CustomUserAgentField
id="codex-custom-user-agent"
value={customUserAgent}
onChange={onCustomUserAgentChange}
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
@@ -588,33 +603,6 @@ export function CodexFormFields({
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
{category !== "official" && (
<div className="space-y-2">
<label
htmlFor="codex-custom-user-agent"
className="block text-sm font-medium text-foreground"
>
{t("providerForm.customUserAgent", {
defaultValue: "自定义 User-Agent",
})}
</label>
<Input
id="codex-custom-user-agent"
type="text"
value={customUserAgent}
onChange={(e) => onCustomUserAgentChange(e.target.value)}
placeholder="Mozilla/5.0 ..."
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t("providerForm.customUserAgentHint", {
defaultValue:
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
})}
</p>
</div>
)}
</>
);
}
@@ -0,0 +1,96 @@
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { isValidUserAgentHeader } from "@/lib/userAgent";
import { USER_AGENT_PRESETS } from "@/config/userAgentPresets";
interface CustomUserAgentFieldProps {
/** 输入框的 id(用于 label htmlFor);两个表单需传入各自唯一值。 */
id: string;
value: string;
onChange: (value: string) => void;
}
/**
* 供应商级自定义 User-Agent 字段(Claude / Codex 表单共用)。
*
* 含标签 + 输入框 + 右侧预设下拉菜单 + 实时合法性提示。校验口径与后端
* `parse_custom_user_agent` 一致(见 `@/lib/userAgent`),非法时给非阻断红字提示
* (运行时仍会静默忽略)。
*/
export function CustomUserAgentField({
id,
value,
onChange,
}: CustomUserAgentFieldProps) {
const { t } = useTranslation();
const valid = isValidUserAgentHeader(value);
return (
<div className="space-y-2">
<FormLabel htmlFor={id}>
{t("providerForm.customUserAgent", {
defaultValue: "自定义 User-Agent",
})}
</FormLabel>
<div className="flex items-center gap-2">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Mozilla/5.0 ..."
autoComplete="off"
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" className="shrink-0 gap-1">
{t("providerForm.customUserAgentPresets", {
defaultValue: "预设",
})}
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto z-[200]"
>
{USER_AGENT_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset}
onSelect={() => onChange(preset)}
className="font-mono text-xs"
>
{preset}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{valid ? (
<p className="text-xs text-muted-foreground">
{t("providerForm.customUserAgentHint", {
defaultValue:
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
})}
</p>
) : (
<p className="text-xs text-destructive">
{t("providerForm.customUserAgentInvalid", {
defaultValue:
"User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
})}
</p>
)}
</div>
);
}