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:
Jason
2025-10-16 18:50:44 +08:00
parent 577998fef2
commit a32aeaf73e
3 changed files with 342 additions and 34 deletions

View File

@@ -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}>

View File

@@ -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";

View 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,
};
}