feat: add Codex support to ProviderForm
- Create useCodexConfigState hook for managing Codex configuration - Handles auth.json (JSON) and config.toml (TOML) separately - Bidirectional sync with Base URL extraction - API Key management from auth.OPENAI_API_KEY - Integrate Codex-specific UI components - Codex API Key input - Codex Base URL input with endpoint speed test - CodexConfigEditor for auth/config editing - Update handlePresetChange to support Codex presets - Update handleSubmit to compose Codex auth+config - Conditional rendering: Claude uses JsonEditor, Codex uses CodexConfigEditor
This commit is contained in:
@@ -25,8 +25,15 @@ import {
|
|||||||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||||||
import ApiKeyInput from "@/components/ProviderForm/ApiKeyInput";
|
import ApiKeyInput from "@/components/ProviderForm/ApiKeyInput";
|
||||||
import EndpointSpeedTest from "@/components/ProviderForm/EndpointSpeedTest";
|
import EndpointSpeedTest from "@/components/ProviderForm/EndpointSpeedTest";
|
||||||
|
import CodexConfigEditor from "@/components/ProviderForm/CodexConfigEditor";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
import { useProviderCategory, useApiKeyState, useBaseUrlState, useModelState } from "./hooks";
|
import {
|
||||||
|
useProviderCategory,
|
||||||
|
useApiKeyState,
|
||||||
|
useBaseUrlState,
|
||||||
|
useModelState,
|
||||||
|
useCodexConfigState,
|
||||||
|
} from "./hooks";
|
||||||
|
|
||||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
||||||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||||||
@@ -129,6 +136,22 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||||||
|
const {
|
||||||
|
codexAuth,
|
||||||
|
codexConfig,
|
||||||
|
codexApiKey,
|
||||||
|
codexBaseUrl,
|
||||||
|
codexAuthError,
|
||||||
|
setCodexAuth,
|
||||||
|
handleCodexApiKeyChange,
|
||||||
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexConfigChange,
|
||||||
|
resetCodexConfig,
|
||||||
|
} = useCodexConfigState({ initialData });
|
||||||
|
|
||||||
|
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
}, [defaultValues, form]);
|
}, [defaultValues, form]);
|
||||||
@@ -142,11 +165,31 @@ export function ProviderForm({
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
|
let settingsConfig: string;
|
||||||
|
|
||||||
|
// Codex: 组合 auth 和 config
|
||||||
|
if (appType === "codex") {
|
||||||
|
try {
|
||||||
|
const authJson = JSON.parse(codexAuth);
|
||||||
|
const configObj = {
|
||||||
|
auth: authJson,
|
||||||
|
config: codexConfig ?? "",
|
||||||
|
};
|
||||||
|
settingsConfig = JSON.stringify(configObj);
|
||||||
|
} catch (err) {
|
||||||
|
// 如果解析失败,使用表单中的配置
|
||||||
|
settingsConfig = values.settingsConfig.trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Claude: 使用表单配置
|
||||||
|
settingsConfig = values.settingsConfig.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const payload: ProviderFormValues = {
|
const payload: ProviderFormValues = {
|
||||||
...values,
|
...values,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||||||
settingsConfig: values.settingsConfig.trim(),
|
settingsConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activePreset) {
|
if (activePreset) {
|
||||||
@@ -216,6 +259,11 @@ export function ProviderForm({
|
|||||||
if (value === "custom") {
|
if (value === "custom") {
|
||||||
setActivePreset(null);
|
setActivePreset(null);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
|
|
||||||
|
// Codex 自定义模式:重置为空配置
|
||||||
|
if (appType === "codex") {
|
||||||
|
resetCodexConfig({}, "");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,15 +279,17 @@ export function ProviderForm({
|
|||||||
|
|
||||||
if (appType === "codex") {
|
if (appType === "codex") {
|
||||||
const preset = entry.preset as CodexProviderPreset;
|
const preset = entry.preset as CodexProviderPreset;
|
||||||
const config = {
|
const auth = preset.auth ?? {};
|
||||||
auth: preset.auth ?? {},
|
const config = preset.config ?? "";
|
||||||
config: preset.config ?? "",
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 重置 Codex 配置
|
||||||
|
resetCodexConfig(auth, config);
|
||||||
|
|
||||||
|
// 更新表单其他字段
|
||||||
form.reset({
|
form.reset({
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
websiteUrl: preset.websiteUrl ?? "",
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
settingsConfig: JSON.stringify(config, null, 2),
|
settingsConfig: JSON.stringify({ auth, config }, null, 2),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -460,34 +510,109 @@ export function ProviderForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{/* Codex API Key 输入框 */}
|
||||||
control={form.control}
|
{appType === "codex" && !isEditMode && (
|
||||||
name="settingsConfig"
|
<div>
|
||||||
render={({ field }) => (
|
<ApiKeyInput
|
||||||
<FormItem>
|
id="codexApiKey"
|
||||||
<FormLabel>
|
label="API Key"
|
||||||
{t("provider.configJson", { defaultValue: "配置 JSON" })}
|
value={codexApiKey}
|
||||||
|
onChange={handleCodexApiKeyChange}
|
||||||
|
required={category !== "official"}
|
||||||
|
placeholder={
|
||||||
|
category === "official"
|
||||||
|
? t("providerForm.codexOfficialNoApiKey", { defaultValue: "官方供应商无需 API Key" })
|
||||||
|
: t("providerForm.codexApiKeyAutoFill", { defaultValue: "输入 API Key,将自动填充到配置" })
|
||||||
|
}
|
||||||
|
disabled={category === "official"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Codex Base URL 输入框 */}
|
||||||
|
{appType === "codex" && shouldShowSpeedTest && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel htmlFor="codexBaseUrl">
|
||||||
|
{t("codexConfig.apiUrlLabel", { defaultValue: "API 端点" })}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<button
|
||||||
<div className="rounded-md border">
|
type="button"
|
||||||
<JsonEditor
|
onClick={() => setIsCodexEndpointModalOpen(true)}
|
||||||
value={field.value}
|
className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||||
onChange={field.onChange}
|
>
|
||||||
placeholder={
|
<Zap className="h-3.5 w-3.5" />
|
||||||
appType === "codex"
|
{t("providerForm.manageAndTest", { defaultValue: "管理和测速" })}
|
||||||
? CODEX_DEFAULT_CONFIG
|
</button>
|
||||||
: CLAUDE_DEFAULT_CONFIG
|
</div>
|
||||||
}
|
<Input
|
||||||
darkMode={isDarkMode}
|
id="codexBaseUrl"
|
||||||
rows={14}
|
type="url"
|
||||||
showValidation
|
value={codexBaseUrl}
|
||||||
/>
|
onChange={(e) => handleCodexBaseUrlChange(e.target.value)}
|
||||||
</div>
|
placeholder={t("providerForm.codexApiEndpointPlaceholder", { defaultValue: "https://api.example.com/v1" })}
|
||||||
</FormControl>
|
autoComplete="off"
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||||
)}
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
/>
|
{t("providerForm.codexApiHint", { defaultValue: "Codex API 端点地址" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 端点测速弹窗 - Codex */}
|
||||||
|
{appType === "codex" && shouldShowSpeedTest && isCodexEndpointModalOpen && (
|
||||||
|
<EndpointSpeedTest
|
||||||
|
appType={appType}
|
||||||
|
value={codexBaseUrl}
|
||||||
|
onChange={handleCodexBaseUrlChange}
|
||||||
|
initialEndpoints={[{ url: codexBaseUrl }]}
|
||||||
|
visible={isCodexEndpointModalOpen}
|
||||||
|
onClose={() => setIsCodexEndpointModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 配置编辑器:Claude 使用 JSON 编辑器,Codex 使用专用编辑器 */}
|
||||||
|
{appType === "codex" ? (
|
||||||
|
<CodexConfigEditor
|
||||||
|
authValue={codexAuth}
|
||||||
|
configValue={codexConfig}
|
||||||
|
onAuthChange={setCodexAuth}
|
||||||
|
onConfigChange={handleCodexConfigChange}
|
||||||
|
useCommonConfig={false}
|
||||||
|
onCommonConfigToggle={() => {}}
|
||||||
|
commonConfigSnippet=""
|
||||||
|
onCommonConfigSnippetChange={() => {}}
|
||||||
|
commonConfigError=""
|
||||||
|
authError={codexAuthError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="settingsConfig"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("provider.configJson", { defaultValue: "配置 JSON" })}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<JsonEditor
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder={CLAUDE_DEFAULT_CONFIG}
|
||||||
|
darkMode={isDarkMode}
|
||||||
|
rows={14}
|
||||||
|
showValidation
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" type="button" onClick={onCancel}>
|
<Button variant="outline" type="button" onClick={onCancel}>
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { useProviderCategory } from "./useProviderCategory";
|
|||||||
export { useApiKeyState } from "./useApiKeyState";
|
export { useApiKeyState } from "./useApiKeyState";
|
||||||
export { useBaseUrlState } from "./useBaseUrlState";
|
export { useBaseUrlState } from "./useBaseUrlState";
|
||||||
export { useModelState } from "./useModelState";
|
export { useModelState } from "./useModelState";
|
||||||
|
export { useCodexConfigState } from "./useCodexConfigState";
|
||||||
|
|||||||
182
src/components/providers/forms/hooks/useCodexConfigState.ts
Normal file
182
src/components/providers/forms/hooks/useCodexConfigState.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { extractCodexBaseUrl, setCodexBaseUrl as setCodexBaseUrlInConfig } from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
|
interface UseCodexConfigStateProps {
|
||||||
|
initialData?: {
|
||||||
|
settingsConfig?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 Codex 配置状态
|
||||||
|
* Codex 配置包含两部分:auth.json (JSON) 和 config.toml (TOML 字符串)
|
||||||
|
*/
|
||||||
|
export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
|
||||||
|
const [codexAuth, setCodexAuthState] = useState("");
|
||||||
|
const [codexConfig, setCodexConfigState] = useState("");
|
||||||
|
const [codexApiKey, setCodexApiKey] = useState("");
|
||||||
|
const [codexBaseUrl, setCodexBaseUrl] = useState("");
|
||||||
|
const [codexAuthError, setCodexAuthError] = useState("");
|
||||||
|
|
||||||
|
const isUpdatingCodexBaseUrlRef = useRef(false);
|
||||||
|
|
||||||
|
// 初始化 Codex 配置(编辑模式)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialData) return;
|
||||||
|
|
||||||
|
const config = initialData.settingsConfig;
|
||||||
|
if (typeof config === "object" && config !== null) {
|
||||||
|
// 设置 auth.json
|
||||||
|
const auth = (config as any).auth || {};
|
||||||
|
setCodexAuthState(JSON.stringify(auth, null, 2));
|
||||||
|
|
||||||
|
// 设置 config.toml
|
||||||
|
const configStr = typeof (config as any).config === "string" ? (config as any).config : "";
|
||||||
|
setCodexConfigState(configStr);
|
||||||
|
|
||||||
|
// 提取 Base URL
|
||||||
|
const initialBaseUrl = extractCodexBaseUrl(configStr);
|
||||||
|
if (initialBaseUrl) {
|
||||||
|
setCodexBaseUrl(initialBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 API Key
|
||||||
|
try {
|
||||||
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
|
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 与 TOML 配置保持基础 URL 同步
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUpdatingCodexBaseUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = extractCodexBaseUrl(codexConfig) || "";
|
||||||
|
if (extracted !== codexBaseUrl) {
|
||||||
|
setCodexBaseUrl(extracted);
|
||||||
|
}
|
||||||
|
}, [codexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 验证 Codex Auth JSON
|
||||||
|
const validateCodexAuth = useCallback((value: string): string => {
|
||||||
|
if (!value.trim()) return "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return "Auth JSON must be an object";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch {
|
||||||
|
return "Invalid JSON format";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 设置 auth 并验证
|
||||||
|
const setCodexAuth = useCallback((value: string) => {
|
||||||
|
setCodexAuthState(value);
|
||||||
|
setCodexAuthError(validateCodexAuth(value));
|
||||||
|
}, [validateCodexAuth]);
|
||||||
|
|
||||||
|
// 设置 config (支持函数更新)
|
||||||
|
const setCodexConfig = useCallback((value: string | ((prev: string) => string)) => {
|
||||||
|
setCodexConfigState((prev) =>
|
||||||
|
typeof value === "function"
|
||||||
|
? (value as (input: string) => string)(prev)
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理 Codex API Key 输入并写回 auth.json
|
||||||
|
const handleCodexApiKeyChange = useCallback((key: string) => {
|
||||||
|
setCodexApiKey(key);
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(codexAuth || "{}");
|
||||||
|
auth.OPENAI_API_KEY = key.trim();
|
||||||
|
setCodexAuth(JSON.stringify(auth, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [codexAuth, setCodexAuth]);
|
||||||
|
|
||||||
|
// 处理 Codex Base URL 变化
|
||||||
|
const handleCodexBaseUrlChange = useCallback((url: string) => {
|
||||||
|
const sanitized = url.trim().replace(/\/+$/, "");
|
||||||
|
setCodexBaseUrl(sanitized);
|
||||||
|
|
||||||
|
if (!sanitized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingCodexBaseUrlRef.current = true;
|
||||||
|
setCodexConfig((prev) => setCodexBaseUrlInConfig(prev, sanitized));
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingCodexBaseUrlRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}, [setCodexConfig]);
|
||||||
|
|
||||||
|
// 处理 config 变化(同步 Base URL)
|
||||||
|
const handleCodexConfigChange = useCallback((value: string) => {
|
||||||
|
setCodexConfig(value);
|
||||||
|
|
||||||
|
if (!isUpdatingCodexBaseUrlRef.current) {
|
||||||
|
const extracted = extractCodexBaseUrl(value) || "";
|
||||||
|
if (extracted !== codexBaseUrl) {
|
||||||
|
setCodexBaseUrl(extracted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setCodexConfig, codexBaseUrl]);
|
||||||
|
|
||||||
|
// 重置配置(用于预设切换)
|
||||||
|
const resetCodexConfig = useCallback((auth: Record<string, unknown>, config: string) => {
|
||||||
|
const authString = JSON.stringify(auth, null, 2);
|
||||||
|
setCodexAuth(authString);
|
||||||
|
setCodexConfig(config);
|
||||||
|
|
||||||
|
const baseUrl = extractCodexBaseUrl(config);
|
||||||
|
if (baseUrl) {
|
||||||
|
setCodexBaseUrl(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 API Key
|
||||||
|
try {
|
||||||
|
if (auth && typeof auth.OPENAI_API_KEY === "string") {
|
||||||
|
setCodexApiKey(auth.OPENAI_API_KEY);
|
||||||
|
} else {
|
||||||
|
setCodexApiKey("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCodexApiKey("");
|
||||||
|
}
|
||||||
|
}, [setCodexAuth, setCodexConfig]);
|
||||||
|
|
||||||
|
// 获取 API Key(从 auth JSON)
|
||||||
|
const getCodexAuthApiKey = useCallback((authString: string): string => {
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(authString || "{}");
|
||||||
|
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
codexAuth,
|
||||||
|
codexConfig,
|
||||||
|
codexApiKey,
|
||||||
|
codexBaseUrl,
|
||||||
|
codexAuthError,
|
||||||
|
setCodexAuth,
|
||||||
|
setCodexConfig,
|
||||||
|
handleCodexApiKeyChange,
|
||||||
|
handleCodexBaseUrlChange,
|
||||||
|
handleCodexConfigChange,
|
||||||
|
resetCodexConfig,
|
||||||
|
getCodexAuthApiKey,
|
||||||
|
validateCodexAuth,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user