Files
CC-Switch/src/components/providers/forms/CodexFormFields.tsx
T
Jason e776160912 feat(provider-form): consolidate codex form into advanced options section
- Fold local routing toggle, model mapping, reasoning overrides and custom
  User-Agent into a single collapsible advanced section, mirroring the
  Claude form (auto-expands when UA is set or local routing is enabled)
- Custom User-Agent becomes configurable for native Responses providers;
  it was previously reachable only when openai_chat routing was on
- Collapsed hint names local routing as the entry point for Chat
  Completions / non-GPT providers
- Backfill all missing codexConfig keys in zh-TW locale
2026-06-11 08:29:29 +08:00

658 lines
24 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import {
ChevronDown,
ChevronRight,
Download,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
import {
fetchModelsForConfig,
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import { CustomUserAgentField } from "./CustomUserAgentField";
import { cn } from "@/lib/utils";
import type {
CodexApiFormat,
CodexCatalogModel,
CodexChatReasoning,
ProviderCategory,
} from "@/types";
interface EndpointCandidate {
url: string;
}
interface CodexFormFieldsProps {
providerId?: string;
// API Key
codexApiKey: string;
onApiKeyChange: (key: string) => void;
category?: ProviderCategory;
shouldShowApiKeyLink: boolean;
websiteUrl: string;
isPartner?: boolean;
partnerPromotionKey?: string;
// Base URL
shouldShowSpeedTest: boolean;
codexBaseUrl: string;
onBaseUrlChange: (url: string) => void;
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// API Format
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
onApiFormatChange: (format: CodexApiFormat) => void;
codexChatReasoning?: CodexChatReasoning;
onCodexChatReasoningChange?: (value: CodexChatReasoning) => void;
// Model Catalog
catalogModels?: CodexCatalogModel[];
onCatalogModelsChange?: (models: CodexCatalogModel[]) => void;
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// Local proxy User-Agent override
customUserAgent: string;
onCustomUserAgentChange: (value: string) => void;
}
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
function createCatalogRow(seed?: Partial<CodexCatalogModel>): CodexCatalogRow {
return {
rowId: crypto.randomUUID(),
model: seed?.model ?? "",
displayName: seed?.displayName ?? "",
contextWindow: seed?.contextWindow ?? "",
};
}
// Compares rows (with rowId) to incoming models (without) by data fields only,
// so both sync effects can use the same equality definition.
function catalogRowsMatchModels(
rows: Array<Pick<CodexCatalogRow, "model" | "displayName" | "contextWindow">>,
models: CodexCatalogModel[],
): boolean {
if (rows.length !== models.length) return false;
return rows.every((row, i) => {
const incoming = models[i];
return (
row.model === (incoming.model ?? "") &&
(row.displayName ?? "") === (incoming.displayName ?? "") &&
String(row.contextWindow ?? "") === String(incoming.contextWindow ?? "")
);
});
}
export function CodexFormFields({
providerId,
codexApiKey,
onApiKeyChange,
category,
shouldShowApiKeyLink,
websiteUrl,
isPartner,
partnerPromotionKey,
shouldShowSpeedTest,
codexBaseUrl,
onBaseUrlChange,
isFullUrl,
onFullUrlChange,
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
apiFormat,
onApiFormatChange,
codexChatReasoning = {},
onCodexChatReasoningChange,
catalogModels = [],
onCatalogModelsChange,
speedTestEndpoints,
customUserAgent,
onCustomUserAgentChange,
}: CodexFormFieldsProps) {
const { t } = useTranslation();
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const needsLocalRouting = apiFormat === "openai_chat";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
const supportsThinking =
codexChatReasoning.supportsThinking === true ||
codexChatReasoning.supportsEffort === true;
const supportsEffort = codexChatReasoning.supportsEffort === true;
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
if (hasAnyAdvancedValue) {
setAdvancedExpanded(true);
}
}, [hasAnyAdvancedValue]);
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
catalogModels.map((m) => createCatalogRow(m)),
);
// 记录上次发送给父组件的数据,避免重复触发
const lastSentModelsRef = useRef<CodexCatalogModel[]>(catalogModels);
// 父 → 子:仅当 prop 数据真的变化(预设切换 / 编辑加载)时才重建 rowId;
// 同 shape 时保留现有 rowId,避免编辑过程中焦点丢失。
useEffect(() => {
setCatalogRows((current) => {
if (catalogRowsMatchModels(current, catalogModels)) return current;
return catalogModels.map((m) => createCatalogRow(m));
});
// 同步更新 ref,避免父组件传入新数据时子→父 effect 误判为本地修改
lastSentModelsRef.current = catalogModels;
}, [catalogModels]);
// 子 → 父:rowId 是视图层概念,不应进入持久化数据;剥离后再回传。
// 注意:依赖数组不包含 catalogModels,避免父→子更新触发子→父回调形成循环。
useEffect(() => {
if (!onCatalogModelsChange) return;
const next: CodexCatalogModel[] = catalogRows.map(
({ rowId: _rowId, ...rest }) => rest,
);
// 只有当数据真的变化时才通知父组件
if (catalogRowsMatchModels(catalogRows, lastSentModelsRef.current)) return;
lastSentModelsRef.current = next;
onCatalogModelsChange(next);
}, [catalogRows, onCatalogModelsChange]);
const handleLocalRoutingChange = useCallback(
(checked: boolean) => {
onApiFormatChange(checked ? "openai_chat" : "openai_responses");
},
[onApiFormatChange],
);
const handleReasoningThinkingChange = useCallback(
(checked: boolean) => {
if (!onCodexChatReasoningChange) return;
onCodexChatReasoningChange({
...codexChatReasoning,
supportsThinking: checked,
supportsEffort: checked ? codexChatReasoning.supportsEffort : false,
});
},
[codexChatReasoning, onCodexChatReasoningChange],
);
const handleReasoningEffortChange = useCallback(
(checked: boolean) => {
if (!onCodexChatReasoningChange) return;
onCodexChatReasoningChange({
...codexChatReasoning,
supportsThinking: checked ? true : codexChatReasoning.supportsThinking,
supportsEffort: checked,
effortParam: checked
? (codexChatReasoning.effortParam ?? "reasoning_effort")
: "none",
});
},
[codexChatReasoning, onCodexChatReasoningChange],
);
const handleFetchModels = useCallback(() => {
if (!codexBaseUrl || !codexApiKey) {
showFetchModelsError(null, t, {
hasApiKey: !!codexApiKey,
hasBaseUrl: !!codexBaseUrl,
});
return;
}
setIsFetchingModels(true);
fetchModelsForConfig(
codexBaseUrl,
codexApiKey,
isFullUrl,
undefined,
customUserAgent,
)
.then((models) => {
setFetchedModels(models);
if (models.length === 0) {
toast.info(t("providerForm.fetchModelsEmpty"));
} else {
toast.success(
t("providerForm.fetchModelsSuccess", { count: models.length }),
);
}
})
.catch((err) => {
console.warn("[ModelFetch] Failed:", err);
showFetchModelsError(err, t);
})
.finally(() => setIsFetchingModels(false));
}, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]);
const handleAddCatalogRow = useCallback(() => {
if (!onCatalogModelsChange) return;
setCatalogRows((current) => [...current, createCatalogRow()]);
}, [onCatalogModelsChange]);
const handleUpdateCatalogRow = useCallback(
(index: number, patch: Partial<CodexCatalogModel>) => {
setCatalogRows((current) =>
current.map((row, i) => (i === index ? { ...row, ...patch } : row)),
);
},
[],
);
const handleRemoveCatalogRow = useCallback((index: number) => {
setCatalogRows((current) => current.filter((_, i) => i !== index));
}, []);
const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleFetchModels}
disabled={isFetchingModels}
className="h-7 gap-1"
>
{isFetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("providerForm.fetchModels")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onAdd}
className="h-7 gap-1"
>
<Plus className="h-3.5 w-3.5" />
{addLabel}
</Button>
</div>
);
return (
<>
{/* Codex API Key 输入框 */}
<ApiKeySection
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={onApiKeyChange}
category={category}
shouldShowLink={shouldShowApiKeyLink}
websiteUrl={websiteUrl}
isPartner={isPartner}
partnerPromotionKey={partnerPromotionKey}
placeholder={{
official: t("providerForm.codexOfficialNoApiKey", {
defaultValue: "官方供应商无需 API Key",
}),
thirdParty: t("providerForm.codexApiKeyAutoFill", {
defaultValue: "输入 API Key,将自动填充到配置",
}),
}}
/>
{/* Codex Base URL 输入框 */}
{shouldShowSpeedTest && (
<EndpointField
id="codexBaseUrl"
label={t("codexConfig.apiUrlLabel")}
value={codexBaseUrl}
onChange={onBaseUrlChange}
placeholder={t("providerForm.codexApiEndpointPlaceholder")}
hint={t("providerForm.codexApiHint")}
showFullUrlToggle
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
onManageClick={() => onEndpointModalToggle(true)}
/>
)}
{/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
{category !== "official" && (
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
className="rounded-lg border border-border-default p-4"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("providerForm.advancedOptionsToggle", {
defaultValue: "高级选项",
})}
</Button>
</CollapsibleTrigger>
{!advancedExpanded && (
<p className="mt-1 ml-1 text-xs text-muted-foreground">
{t("codexConfig.advancedSectionHint", {
defaultValue:
"包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。",
})}
</p>
)}
<CollapsibleContent className="space-y-3 pt-3">
{/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */}
{shouldShowSpeedTest && (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{needsLocalRouting
? t("codexConfig.localRoutingOnHint", {
defaultValue:
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
})
: t("codexConfig.localRoutingOffHint", {
defaultValue:
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
})}
</p>
</div>
<Switch
checked={needsLocalRouting}
onCheckedChange={handleLocalRoutingChange}
aria-label={t("codexConfig.localRoutingToggle", {
defaultValue: "需要本地路由映射",
})}
/>
</div>
)}
{needsLocalRouting && canEditReasoning && (
<div
className={cn(
"space-y-3",
shouldShowSpeedTest && "border-t border-border-default pt-3",
)}
>
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningGroupTitle", {
defaultValue: "思考能力",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningSectionHint", {
defaultValue:
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
})}
</p>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningModeHint", {
defaultValue:
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
})}
</p>
</div>
<Switch
checked={supportsThinking}
onCheckedChange={handleReasoningThinkingChange}
aria-label={t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningEffortHint", {
defaultValue:
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
})}
</p>
</div>
<Switch
checked={supportsEffort}
onCheckedChange={handleReasoningEffortChange}
aria-label={t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
/>
</div>
</div>
)}
<div
className={cn(
(shouldShowSpeedTest ||
(needsLocalRouting && canEditReasoning)) &&
"border-t border-border-default pt-3",
)}
>
<CustomUserAgentField
id="codex-custom-user-agent"
value={customUserAgent}
onChange={onCustomUserAgentChange}
/>
</div>
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
{needsLocalRouting && canEditCatalog && (
<div className="space-y-4 border-t border-border-default pt-3">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<FormLabel>
{t("codexConfig.modelMappingTitle", {
defaultValue: "模型映射",
})}
</FormLabel>
{renderCatalogActionButtons(
handleAddCatalogRow,
t("codexConfig.addCatalogModel", {
defaultValue: "添加模型",
}),
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.modelMappingHint", {
defaultValue:
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
})}
</p>
</div>
{catalogRows.length > 0 && (
<div className="space-y-2">
{/* 列头:md+ 显示 */}
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
<span>
{t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
</span>
<span>
{t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
</span>
<span>
{t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
</span>
<span />
</div>
{catalogRows.map((row, index) => (
<div
key={row.rowId}
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
>
<Input
value={row.displayName ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
displayName: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogDisplayNamePlaceholder",
{
defaultValue: "例如: DeepSeek V4 Flash",
},
)}
aria-label={t("codexConfig.catalogColumnDisplay", {
defaultValue: "菜单显示名",
})}
/>
<div className="flex gap-1">
<Input
value={row.model}
onChange={(event) =>
handleUpdateCatalogRow(index, {
model: event.target.value,
})
}
placeholder={t(
"codexConfig.catalogModelPlaceholder",
{
defaultValue: "例如: deepseek-v4-flash",
},
)}
aria-label={t("codexConfig.catalogColumnModel", {
defaultValue: "实际请求模型",
})}
className="flex-1"
/>
{fetchedModels.length > 0 && (
<ModelDropdown
models={fetchedModels}
onSelect={(id) =>
handleUpdateCatalogRow(index, {
model: id,
displayName: row.displayName?.trim()
? row.displayName
: id,
})
}
/>
)}
</div>
<Input
type="number"
min={1}
inputMode="numeric"
value={row.contextWindow ?? ""}
onChange={(event) =>
handleUpdateCatalogRow(index, {
contextWindow: event.target.value.replace(
/[^\d]/g,
"",
),
})
}
placeholder={t(
"codexConfig.contextWindowPlaceholder",
{
defaultValue: "例如: 128000",
},
)}
aria-label={t("codexConfig.catalogColumnContext", {
defaultValue: "上下文窗口",
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveCatalogRow(index)}
title={t("common.delete", { defaultValue: "删除" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
{/* 端点测速弹窗 - Codex */}
{shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appId="codex"
providerId={providerId}
value={codexBaseUrl}
onChange={onBaseUrlChange}
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
</>
);
}