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): 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>, 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([]); 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(() => catalogModels.map((m) => createCatalogRow(m)), ); // 记录上次发送给父组件的数据,避免重复触发 const lastSentModelsRef = useRef(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) => { 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) => (
); return ( <> {/* Codex API Key 输入框 */} {/* Codex Base URL 输入框 */} {shouldShowSpeedTest && ( onEndpointModalToggle(true)} /> )} {/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */} {category !== "official" && ( {!advancedExpanded && (

{t("codexConfig.advancedSectionHint", { defaultValue: "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。", })}

)} {/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */} {shouldShowSpeedTest && (
{t("codexConfig.localRoutingToggle", { defaultValue: "需要本地路由映射", })}

{needsLocalRouting ? t("codexConfig.localRoutingOnHint", { defaultValue: "Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。", }) : t("codexConfig.localRoutingOffHint", { defaultValue: "如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。", })}

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

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

{t("codexConfig.reasoningModeToggle", { defaultValue: "支持思考模式", })}

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

{t("codexConfig.reasoningEffortToggle", { defaultValue: "支持思考等级", })}

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

)}
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */} {needsLocalRouting && canEditCatalog && (
{t("codexConfig.modelMappingTitle", { defaultValue: "模型映射", })} {renderCatalogActionButtons( handleAddCatalogRow, t("codexConfig.addCatalogModel", { defaultValue: "添加模型", }), )}

{t("codexConfig.modelMappingHint", { defaultValue: "选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。", })}

{catalogRows.length > 0 && (
{/* 列头:md+ 显示 */}
{t("codexConfig.catalogColumnDisplay", { defaultValue: "菜单显示名", })} {t("codexConfig.catalogColumnModel", { defaultValue: "实际请求模型", })} {t("codexConfig.catalogColumnContext", { defaultValue: "上下文窗口", })}
{catalogRows.map((row, index) => (
handleUpdateCatalogRow(index, { displayName: event.target.value, }) } placeholder={t( "codexConfig.catalogDisplayNamePlaceholder", { defaultValue: "例如: DeepSeek V4 Flash", }, )} aria-label={t("codexConfig.catalogColumnDisplay", { defaultValue: "菜单显示名", })} />
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 && ( handleUpdateCatalogRow(index, { model: id, displayName: row.displayName?.trim() ? row.displayName : id, }) } /> )}
handleUpdateCatalogRow(index, { contextWindow: event.target.value.replace( /[^\d]/g, "", ), }) } placeholder={t( "codexConfig.contextWindowPlaceholder", { defaultValue: "例如: 128000", }, )} aria-label={t("codexConfig.catalogColumnContext", { defaultValue: "上下文窗口", })} />
))}
)}
)}
)} {/* 端点测速弹窗 - Codex */} {shouldShowSpeedTest && isEndpointModalOpen && ( onEndpointModalToggle(false)} autoSelect={autoSelect} onAutoSelectChange={onAutoSelectChange} onCustomEndpointsChange={onCustomEndpointsChange} /> )} ); }