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 { toast } from "sonner"; import { 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 type { CodexApiFormat, CodexCatalogModel, 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; // Model Catalog catalogModels?: CodexCatalogModel[]; onCatalogModelsChange?: (models: CodexCatalogModel[]) => void; // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; } 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, catalogModels = [], onCatalogModelsChange, speedTestEndpoints, }: CodexFormFieldsProps) { const { t } = useTranslation(); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); const needsLocalRouting = apiFormat === "openai_chat"; const canEditCatalog = Boolean(onCatalogModelsChange); 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 handleFetchModels = useCallback(() => { if (!codexBaseUrl || !codexApiKey) { showFetchModelsError(null, t, { hasApiKey: !!codexApiKey, hasBaseUrl: !!codexBaseUrl, }); return; } setIsFetchingModels(true); fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl) .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, 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)} /> )} {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 系列,请打开此开关。", })}

)} {/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */} {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} /> )} ); }