Files
cc-switch/src/components/ProviderForm.tsx
T
YoVinchen ca488cf076 feat: Implement Speed Test Function
* feat: add unified endpoint speed test for API providers

Add a comprehensive endpoint latency testing system that allows users to:
- Test multiple API endpoints concurrently
- Auto-select the fastest endpoint based on latency
- Add/remove custom endpoints dynamically
- View latency results with color-coded indicators

Backend (Rust):
- Implement parallel HTTP HEAD requests with configurable timeout
- Handle various error scenarios (timeout, connection failure, invalid URL)
- Return structured latency data with status codes

Frontend (React):
- Create interactive speed test UI component with auto-sort by latency
- Support endpoint management (add/remove custom endpoints)
- Extract and update Codex base_url from TOML configuration
- Integrate with provider presets for default endpoint candidates

This feature improves user experience when selecting optimal API endpoints,
especially useful for users with multiple provider options or proxy setups.

* refactor: convert endpoint speed test to modal dialog

- Transform EndpointSpeedTest component into a modal dialog
- Add "Advanced" button next to base URL input to open modal
- Support ESC key and backdrop click to close modal
- Apply Linear design principles: minimal styling, clean layout
- Remove unused showBaseUrlInput variable
- Implement same modal pattern for both Claude and Codex

* fix: prevent modal cascade closing when ESC is pressed

- Add state checks to prevent parent modal from closing when child modals (endpoint speed test or template wizard) are open
- Update ESC key handler dependencies to track all modal states
- Ensures only the topmost modal responds to ESC key

* refactor: unify speed test panel UI with project design system

UI improvements:
- Update modal border radius from rounded-lg to rounded-xl
- Unify header padding from px-6 py-4 to p-6
- Change speed test button color to blue theme (bg-blue-500) for consistency
- Update footer background from bg-gray-50 to bg-gray-100
- Style "Done" button as primary action button with blue theme
- Adjust footer button spacing and hover states

Simplify endpoint display:
- Remove endpoint labels (e.g., "Current Address", "Custom 1")
- Display only URL for cleaner interface
- Clean up all label-related logic:
  * Remove label field from EndpointCandidate interface
  * Remove label generation in buildInitialEntries function
  * Remove label handling in useEffect merge logic
  * Remove label generation in handleAddEndpoint
  * Remove label parameters from claudeSpeedTestEndpoints
  * Remove label parameters from codexSpeedTestEndpoints

* refactor: improve endpoint list UI consistency

- Show delete button for all endpoints on hover for uniform UI
- Change selected state to use blue theme matching main interface:
  * Blue border (border-blue-500) for selected items
  * Light blue background (bg-blue-50/dark:bg-blue-900/20)
  * Blue indicator dot (bg-blue-500/dark:bg-blue-400)
- Switch from compact list (space-y-px) to card-based layout (space-y-2)
- Add rounded corners to each endpoint item for better visual separation

* feat: persist custom endpoints to settings.json

- Extend AppSettings to store custom endpoints for Claude and Codex
- Add Tauri commands: get/add/remove/update custom endpoints
- Update frontend API with endpoint persistence methods
- Modify EndpointSpeedTest to load/save custom endpoints via API
- Track endpoint last used time for future sorting/cleanup
- Store endpoints per app type in settings.json instead of localStorage

* - feat(types): add Provider.meta and ProviderMeta (snake_case) with custom_endpoints map

- feat(provider-form): persist custom endpoints on provider create by merging EndpointSpeedTest’s custom URLs into meta.custom_endpoints on submit

- feat(endpoint-speed-test): add onCustomEndpointsChange callback emitting normalized custom URLs; wire it for both Claude/Codex modals

- fix(api): send alias param names (app/appType/app_type and provider_id/providerId) in Tauri invokes to avoid “missing providerId” with older backends

- storage: custom endpoints are stored in ~/.cc-switch/config.json under providers[<id>].meta.custom_endpoints (not in settings.json)

- behavior: edit flow remains immediate writes; create flow now writes once via addProvider, removing the providerId dependency during creation

* feat: add endpoint candidates support and code formatting improvements

- Add endpointCandidates field to ProviderPreset and CodexProviderPreset interfaces
- Integrate preset endpoint candidates into speed test endpoint selection
- Add multiple endpoint options for PackyCode providers (Claude & Codex)
- Apply consistent code formatting (trailing commas, line breaks)
- Improve template value type safety and readability

* refactor: improve endpoint management button UX

Replace ambiguous "Advanced" text with intuitive "Manage & Test" label accompanied by Zap icon, making the endpoint management panel entry point more discoverable and self-explanatory for both Claude and Codex configurations.

* - merge: merge origin/main, resolve conflicts and preserve both feature sets
- feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints
- feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs
- feat(types): extend global API declarations (import/export)
- chore(presets): GLM preset supports both new and legacy model keys
- chore(rust): add chrono dependency; refresh lockfile

---------

Co-authored-by: Jason <farion1231@gmail.com>
2025-10-07 19:14:32 +08:00

1892 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
import { AppType } from "../lib/tauri-api";
import {
updateCommonConfigSnippet,
hasCommonConfigSnippet,
getApiKeyFromConfig,
hasApiKeyField,
setApiKeyInConfig,
updateTomlCommonConfigSnippet,
hasTomlCommonConfigSnippet,
validateJsonConfig,
applyTemplateValues,
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import type { TemplateValueConfig } from "../config/providerPresets";
import {
codexProviderPresets,
generateThirdPartyAuth,
generateThirdPartyConfig,
} from "../config/codexProviderPresets";
import PresetSelector from "./ProviderForm/PresetSelector";
import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save, Zap } from "lucide-react";
import { isLinux } from "../lib/platform";
import EndpointSpeedTest, {
EndpointCandidate,
} from "./ProviderForm/EndpointSpeedTest";
// 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件
type TemplateValueMap = Record<string, TemplateValueConfig>;
type TemplatePath = Array<string | number>;
const collectTemplatePaths = (
source: unknown,
templateKeys: string[],
currentPath: TemplatePath = [],
acc: TemplatePath[] = []
): TemplatePath[] => {
if (typeof source === "string") {
const hasPlaceholder = templateKeys.some((key) =>
source.includes(`\${${key}}`)
);
if (hasPlaceholder) {
acc.push([...currentPath]);
}
return acc;
}
if (Array.isArray(source)) {
source.forEach((item, index) =>
collectTemplatePaths(item, templateKeys, [...currentPath, index], acc)
);
return acc;
}
if (source && typeof source === "object") {
Object.entries(source).forEach(([key, value]) =>
collectTemplatePaths(value, templateKeys, [...currentPath, key], acc)
);
}
return acc;
};
const getValueAtPath = (source: any, path: TemplatePath) => {
return path.reduce<any>((acc, key) => {
if (acc === undefined || acc === null) {
return undefined;
}
return acc[key as keyof typeof acc];
}, source);
};
const setValueAtPath = (
target: any,
path: TemplatePath,
value: unknown
): any => {
if (path.length === 0) {
return value;
}
let current = target;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
const nextKey = path[i + 1];
const isNextIndex = typeof nextKey === "number";
if (current[key as keyof typeof current] === undefined) {
current[key as keyof typeof current] = isNextIndex ? [] : {};
} else {
const currentValue = current[key as keyof typeof current];
if (isNextIndex && !Array.isArray(currentValue)) {
current[key as keyof typeof current] = [];
} else if (
!isNextIndex &&
(typeof currentValue !== "object" || currentValue === null)
) {
current[key as keyof typeof current] = {};
}
}
current = current[key as keyof typeof current];
}
const finalKey = path[path.length - 1];
current[finalKey as keyof typeof current] = value;
return target;
};
const applyTemplateValuesToConfigString = (
presetConfig: any,
currentConfigString: string,
values: TemplateValueMap
) => {
const replacedConfig = applyTemplateValues(presetConfig, values);
const templateKeys = Object.keys(values);
if (templateKeys.length === 0) {
return JSON.stringify(replacedConfig, null, 2);
}
const placeholderPaths = collectTemplatePaths(presetConfig, templateKeys);
try {
const parsedConfig = currentConfigString.trim()
? JSON.parse(currentConfigString)
: {};
let targetConfig: any;
if (Array.isArray(parsedConfig)) {
targetConfig = [...parsedConfig];
} else if (parsedConfig && typeof parsedConfig === "object") {
targetConfig = JSON.parse(JSON.stringify(parsedConfig));
} else {
targetConfig = {};
}
if (placeholderPaths.length === 0) {
return JSON.stringify(targetConfig, null, 2);
}
let mutatedConfig = targetConfig;
for (const path of placeholderPaths) {
const nextValue = getValueAtPath(replacedConfig, path);
if (path.length === 0) {
mutatedConfig = nextValue;
} else {
setValueAtPath(mutatedConfig, path, nextValue);
}
}
return JSON.stringify(mutatedConfig, null, 2);
} catch {
return JSON.stringify(replacedConfig, null, 2);
}
};
const COMMON_CONFIG_STORAGE_KEY = "cc-switch:common-config-snippet";
const CODEX_COMMON_CONFIG_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
interface ProviderFormProps {
appType?: AppType;
title: string;
submitText: string;
initialData?: Provider;
showPresets?: boolean;
onSubmit: (data: Omit<Provider, "id">) => void;
onClose: () => void;
}
const ProviderForm: React.FC<ProviderFormProps> = ({
appType = "claude",
title,
submitText,
initialData,
showPresets = false,
onSubmit,
onClose,
}) => {
// 对于 Codex,需要分离 auth 和 config
const isCodex = appType === "codex";
const [formData, setFormData] = useState({
name: initialData?.name || "",
websiteUrl: initialData?.websiteUrl || "",
settingsConfig: initialData
? JSON.stringify(initialData.settingsConfig, null, 2)
: "",
});
const [category, setCategory] = useState<ProviderCategory | undefined>(
initialData?.category
);
// Claude 模型配置状态
const [claudeModel, setClaudeModel] = useState("");
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
// 模板变量状态
const [templateValues, setTemplateValues] = useState<
Record<string, TemplateValueConfig>
>({});
// Codex 特有的状态
const [codexAuth, setCodexAuthState] = useState("");
const [codexConfig, setCodexConfigState] = useState("");
const [codexApiKey, setCodexApiKey] = useState("");
const [codexBaseUrl, setCodexBaseUrl] = useState("");
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
// 新建供应商:收集端点测速弹窗中的“自定义端点”,提交时一次性落盘到 meta.custom_endpoints
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
[]
);
// 端点测速弹窗状态
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null
);
const setCodexAuth = (value: string) => {
setCodexAuthState(value);
setCodexAuthError(validateCodexAuth(value));
};
const setCodexConfig = (value: string | ((prev: string) => string)) => {
setCodexConfigState((prev) =>
typeof value === "function"
? (value as (input: string) => string)(prev)
: value
);
};
const setCodexCommonConfigSnippet = (value: string) => {
setCodexCommonConfigSnippetState(value);
};
// 初始化 Codex 配置
useEffect(() => {
if (isCodex && initialData) {
const config = initialData.settingsConfig;
if (typeof config === "object" && config !== null) {
setCodexAuth(JSON.stringify(config.auth || {}, null, 2));
setCodexConfig(config.config || "");
const initialBaseUrl = extractCodexBaseUrl(config.config);
if (initialBaseUrl) {
setCodexBaseUrl(initialBaseUrl);
}
try {
const auth = config.auth || {};
if (auth && typeof auth.OPENAI_API_KEY === "string") {
setCodexApiKey(auth.OPENAI_API_KEY);
}
} catch {
// ignore
}
}
}
}, [isCodex, initialData]);
const [error, setError] = useState("");
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippet] = useState<string>(() => {
if (typeof window === "undefined") {
return DEFAULT_COMMON_CONFIG_SNIPPET;
}
try {
const stored = window.localStorage.getItem(COMMON_CONFIG_STORAGE_KEY);
if (stored && stored.trim()) {
return stored;
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_COMMON_CONFIG_SNIPPET;
});
const [commonConfigError, setCommonConfigError] = useState("");
const [settingsConfigError, setSettingsConfigError] = useState("");
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// Codex 通用配置状态
const [useCodexCommonConfig, setUseCodexCommonConfig] = useState(false);
const [codexCommonConfigSnippet, setCodexCommonConfigSnippetState] =
useState<string>(() => {
if (typeof window === "undefined") {
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
}
try {
const stored = window.localStorage.getItem(
CODEX_COMMON_CONFIG_STORAGE_KEY
);
if (stored && stored.trim()) {
return stored.trim();
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_CODEX_COMMON_CONFIG_SNIPPET.trim();
});
const [codexCommonConfigError, setCodexCommonConfigError] = useState("");
const isUpdatingFromCodexCommonConfig = useRef(false);
const isUpdatingBaseUrlRef = useRef(false);
const isUpdatingCodexBaseUrlRef = useRef(false);
// -1 表示自定义,null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null
);
const [apiKey, setApiKey] = useState("");
const [codexAuthError, setCodexAuthError] = useState("");
// Kimi 模型选择状态
const [kimiAnthropicModel, setKimiAnthropicModel] = useState("");
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
useState("");
const validateSettingsConfig = (value: string): string => {
return validateJsonConfig(value, "配置内容");
};
const validateCodexAuth = (value: string): string => {
if (!value.trim()) {
return "";
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "auth.json 必须是 JSON 对象";
}
return "";
} catch {
return "auth.json 格式错误,请检查JSON语法";
}
};
const updateSettingsConfigValue = (value: string) => {
setFormData((prev) => ({
...prev,
settingsConfig: value,
}));
setSettingsConfigError(validateSettingsConfig(value));
};
// 初始化自定义模式的默认配置
useEffect(() => {
if (
showPresets &&
selectedPreset === -1 &&
!initialData &&
formData.settingsConfig === ""
) {
// 设置自定义模板
const customTemplate = {
env: {
ANTHROPIC_BASE_URL: "https://your-api-endpoint.com",
ANTHROPIC_AUTH_TOKEN: "",
// 可选配置
// ANTHROPIC_MODEL: "your-model-name",
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
},
};
const templateString = JSON.stringify(customTemplate, null, 2);
updateSettingsConfigValue(templateString);
setApiKey("");
}
}, []); // 只在组件挂载时执行一次
// 初始化时检查通用配置片段
useEffect(() => {
if (initialData) {
if (!isCodex) {
const configString = JSON.stringify(
initialData.settingsConfig,
null,
2
);
const hasCommon = hasCommonConfigSnippet(
configString,
commonConfigSnippet
);
setUseCommonConfig(hasCommon);
setSettingsConfigError(validateSettingsConfig(configString));
// 初始化模型配置(编辑模式)
if (
initialData.settingsConfig &&
typeof initialData.settingsConfig === "object"
) {
const config = initialData.settingsConfig as {
env?: Record<string, any>;
};
if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
// 初始化 Kimi 模型选择
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
}
} else {
// Codex 初始化时检查 TOML 通用配置
const hasCommon = hasTomlCommonConfigSnippet(
codexConfig,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
}
}, [
initialData,
commonConfigSnippet,
codexCommonConfigSnippet,
isCodex,
codexConfig,
]);
// 当选择预设变化时,同步类别
useEffect(() => {
if (!showPresets) return;
if (!isCodex) {
if (selectedPreset !== null && selectedPreset >= 0) {
const preset = providerPresets[selectedPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedPreset === -1) {
setCategory("custom");
}
} else {
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
const preset = codexProviderPresets[selectedCodexPreset];
setCategory(
preset?.category || (preset?.isOfficial ? "official" : undefined)
);
} else if (selectedCodexPreset === -1) {
setCategory("custom");
}
}
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
// 与 JSON 配置保持基础 URL 同步(Claude 第三方/自定义)
useEffect(() => {
if (isCodex) return;
const currentCategory = category ?? initialData?.category;
if (currentCategory !== "third_party" && currentCategory !== "custom") {
return;
}
if (isUpdatingBaseUrlRef.current) {
return;
}
try {
const config = JSON.parse(formData.settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string" && envUrl && envUrl !== baseUrl) {
setBaseUrl(envUrl.trim());
}
} catch {
// ignore JSON parse errors
}
}, [isCodex, category, initialData, formData.settingsConfig, baseUrl]);
// 与 TOML 配置保持基础 URL 同步(Codex 第三方/自定义)
useEffect(() => {
if (!isCodex) return;
const currentCategory = category ?? initialData?.category;
if (currentCategory !== "third_party" && currentCategory !== "custom") {
return;
}
if (isUpdatingCodexBaseUrlRef.current) {
return;
}
const extracted = extractCodexBaseUrl(codexConfig) || "";
if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted);
}
}, [isCodex, category, initialData, codexConfig, codexBaseUrl]);
// 同步本地存储的通用配置片段
useEffect(() => {
if (typeof window === "undefined") return;
try {
if (commonConfigSnippet.trim()) {
window.localStorage.setItem(
COMMON_CONFIG_STORAGE_KEY,
commonConfigSnippet
);
} else {
window.localStorage.removeItem(COMMON_CONFIG_STORAGE_KEY);
}
} catch {
// ignore
}
}, [commonConfigSnippet]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.name) {
setError("请填写供应商名称");
return;
}
let settingsConfig: Record<string, any>;
if (isCodex) {
const currentAuthError = validateCodexAuth(codexAuth);
setCodexAuthError(currentAuthError);
if (currentAuthError) {
setError(currentAuthError);
return;
}
// Codex: 仅要求 auth.json 必填;config.toml 可为空
if (!codexAuth.trim()) {
setError("请填写 auth.json 配置");
return;
}
try {
const authJson = JSON.parse(codexAuth);
// 非官方预设强制要求 OPENAI_API_KEY
if (selectedCodexPreset !== null) {
const preset = codexProviderPresets[selectedCodexPreset];
const isOfficial = Boolean(preset?.isOfficial);
if (!isOfficial) {
const key =
typeof authJson.OPENAI_API_KEY === "string"
? authJson.OPENAI_API_KEY.trim()
: "";
if (!key) {
setError("请填写 OPENAI_API_KEY");
return;
}
}
}
settingsConfig = {
auth: authJson,
config: codexConfig ?? "",
};
} catch (err) {
setError("auth.json 格式错误,请检查JSON语法");
return;
}
} else {
const currentSettingsError = validateSettingsConfig(
formData.settingsConfig
);
setSettingsConfigError(currentSettingsError);
if (currentSettingsError) {
setError(currentSettingsError);
return;
}
if (selectedTemplatePreset && templateValueEntries.length > 0) {
for (const [key, config] of templateValueEntries) {
const entry = templateValues[key];
const resolvedValue = (
entry?.editorValue ??
entry?.defaultValue ??
config.defaultValue ??
""
).trim();
if (!resolvedValue) {
setError(`请填写 ${config.label}`);
return;
}
}
}
// Claude: 原有逻辑
if (!formData.settingsConfig.trim()) {
setError("请填写配置内容");
return;
}
try {
settingsConfig = JSON.parse(formData.settingsConfig);
} catch (err) {
setError("配置JSON格式错误,请检查语法");
return;
}
}
// 构造基础提交数据
const basePayload: Omit<Provider, "id"> = {
name: formData.name,
websiteUrl: formData.websiteUrl,
settingsConfig,
// 仅在用户选择了预设或手动选择“自定义”时持久化分类
...(category ? { category } : {}),
};
// 若为“新建供应商”,且已在弹窗中添加了自定义端点,则随提交一并落盘
if (!initialData && draftCustomEndpoints.length > 0) {
const now = Date.now();
const customMap: Record<string, CustomEndpoint> = {};
for (const raw of draftCustomEndpoints) {
const url = raw.trim().replace(/\/+$/, "");
if (!url) continue;
if (!customMap[url]) {
customMap[url] = { url, addedAt: now };
}
}
onSubmit({ ...basePayload, meta: { custom_endpoints: customMap } });
return;
}
onSubmit(basePayload);
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
if (name === "settingsConfig") {
// 只有在不是通过通用配置更新时,才检查并同步选择框状态
if (!isUpdatingFromCommonConfig.current) {
const hasCommon = hasCommonConfigSnippet(value, commonConfigSnippet);
setUseCommonConfig(hasCommon);
}
// 同步 API Key 输入框显示与值
const parsedKey = getApiKeyFromConfig(value);
setApiKey(parsedKey);
// 不再从 JSON 自动提取或覆盖官网地址,只更新配置内容
updateSettingsConfigValue(value);
} else {
setFormData((prev) => ({
...prev,
[name]: value,
}));
}
};
// 处理通用配置开关
const handleCommonConfigToggle = (checked: boolean) => {
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
formData.settingsConfig,
commonConfigSnippet,
checked
);
if (snippetError) {
setCommonConfigError(snippetError);
if (snippetError.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误,请检查语法");
}
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
updateSettingsConfigValue(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
};
const handleCommonConfigSnippetChange = (value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippet(value);
if (!value.trim()) {
setCommonConfigError("");
if (useCommonConfig) {
const { updatedConfig } = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false
);
// 直接更新 formData,不通过 handleChange
updateSettingsConfigValue(updatedConfig);
setUseCommonConfig(false);
}
return;
}
// 验证JSON格式
const validationError = validateJsonConfig(value, "通用配置片段");
if (validationError) {
setCommonConfigError(validationError);
} else {
setCommonConfigError("");
}
// 若当前启用通用配置且格式正确,需要替换为最新片段
if (useCommonConfig && !validationError) {
const removeResult = updateCommonConfigSnippet(
formData.settingsConfig,
previousSnippet,
false
);
if (removeResult.error) {
setCommonConfigError(removeResult.error);
if (removeResult.error.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误,请检查语法");
}
return;
}
const addResult = updateCommonConfigSnippet(
removeResult.updatedConfig,
value,
true
);
if (addResult.error) {
setCommonConfigError(addResult.error);
if (addResult.error.includes("配置 JSON 解析失败")) {
setSettingsConfigError("配置JSON格式错误,请检查语法");
}
return;
}
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
updateSettingsConfigValue(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
// 保存通用配置到 localStorage
if (!validationError && typeof window !== "undefined") {
try {
window.localStorage.setItem(COMMON_CONFIG_STORAGE_KEY, value);
} catch {
// ignore localStorage 写入失败
}
}
};
const applyPreset = (preset: (typeof providerPresets)[0], index: number) => {
let appliedSettingsConfig = preset.settingsConfig;
let initialTemplateValues: TemplateValueMap = {};
if (preset.templateValues) {
initialTemplateValues = Object.fromEntries(
Object.entries(preset.templateValues).map(([key, config]) => [
key,
{
...config,
editorValue: config.editorValue
? config.editorValue
: (config.defaultValue ?? ""),
},
])
);
appliedSettingsConfig = applyTemplateValues(
preset.settingsConfig,
initialTemplateValues
);
}
setTemplateValues(initialTemplateValues);
const configString = JSON.stringify(appliedSettingsConfig, null, 2);
setFormData({
name: preset.name,
websiteUrl: preset.websiteUrl,
settingsConfig: configString,
});
setSettingsConfigError(validateSettingsConfig(configString));
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 设置选中的预设
setSelectedPreset(index);
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
// 同步通用配置状态
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
setUseCommonConfig(hasCommon);
setCommonConfigError("");
// 如果预设包含模型配置,初始化模型输入框
if (appliedSettingsConfig && typeof appliedSettingsConfig === "object") {
const config = appliedSettingsConfig as { env?: Record<string, any> };
if (config.env) {
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
const presetBaseUrl =
typeof config.env.ANTHROPIC_BASE_URL === "string"
? config.env.ANTHROPIC_BASE_URL
: "";
setBaseUrl(presetBaseUrl);
// 如果是 Kimi 预设,同步 Kimi 模型选择
if (preset.name?.includes("Kimi")) {
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
setKimiAnthropicSmallFastModel(
config.env.ANTHROPIC_SMALL_FAST_MODEL || ""
);
}
} else {
setClaudeModel("");
setClaudeSmallFastModel("");
setBaseUrl("");
}
}
};
// 处理点击自定义按钮
const handleCustomClick = () => {
setSelectedPreset(-1);
setTemplateValues({});
// 设置自定义模板
const customTemplate = {
env: {
ANTHROPIC_BASE_URL: "https://your-api-endpoint.com",
ANTHROPIC_AUTH_TOKEN: "",
// 可选配置
// ANTHROPIC_MODEL: "your-model-name",
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
},
};
const templateString = JSON.stringify(customTemplate, null, 2);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: templateString,
});
setSettingsConfigError(validateSettingsConfig(templateString));
setApiKey("");
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
setUseCommonConfig(false);
setCommonConfigError("");
setClaudeModel("");
setClaudeSmallFastModel("");
setKimiAnthropicModel("");
setKimiAnthropicSmallFastModel("");
setCategory("custom");
};
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
setCodexConfig(preset.config || "");
const presetBaseUrl = extractCodexBaseUrl(preset.config);
if (presetBaseUrl) {
setCodexBaseUrl(presetBaseUrl);
}
setFormData((prev) => ({
...prev,
name: preset.name,
websiteUrl: preset.websiteUrl,
}));
setSelectedCodexPreset(index);
setCategory(
preset.category || (preset.isOfficial ? "official" : undefined)
);
// 清空 API Key,让用户重新输入
setCodexApiKey("");
};
// Codex: 处理点击自定义按钮
const handleCodexCustomClick = () => {
setSelectedCodexPreset(-1);
// 设置自定义模板
const customAuth = generateThirdPartyAuth("");
const customConfig = generateThirdPartyConfig(
"custom",
"https://your-api-endpoint.com/v1",
"gpt-5-codex"
);
setFormData({
name: "",
websiteUrl: "",
settingsConfig: "",
});
setSettingsConfigError(validateSettingsConfig(""));
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCodexBaseUrl("https://your-api-endpoint.com/v1");
setCategory("custom");
};
// 处理 API Key 输入并自动更新配置
const handleApiKeyChange = (key: string) => {
setApiKey(key);
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
);
// 更新表单配置
updateSettingsConfigValue(configString);
// 同步通用配置开关
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
setUseCommonConfig(hasCommon);
};
// 处理基础 URL 变化
const handleBaseUrlChange = (url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
setBaseUrl(sanitized);
isUpdatingBaseUrlRef.current = true;
try {
const config = JSON.parse(formData.settingsConfig || "{}");
if (!config.env) {
config.env = {};
}
config.env.ANTHROPIC_BASE_URL = sanitized;
updateSettingsConfigValue(JSON.stringify(config, null, 2));
} catch {
// ignore
} finally {
setTimeout(() => {
isUpdatingBaseUrlRef.current = false;
}, 0);
}
};
const handleCodexBaseUrlChange = (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);
};
// Codex: 处理 API Key 输入并写回 auth.json
const handleCodexApiKeyChange = (key: string) => {
setCodexApiKey(key);
try {
const auth = JSON.parse(codexAuth || "{}");
auth.OPENAI_API_KEY = key.trim();
setCodexAuth(JSON.stringify(auth, null, 2));
} catch {
// ignore
}
};
// Codex: 处理通用配置开关
const handleCodexCommonConfigToggle = (checked: boolean) => {
const snippet = codexCommonConfigSnippet.trim();
const { updatedConfig, error: snippetError } =
updateTomlCommonConfigSnippet(codexConfig, snippet, checked);
if (snippetError) {
setCodexCommonConfigError(snippetError);
setUseCodexCommonConfig(false);
return;
}
setCodexCommonConfigError("");
setUseCodexCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCodexCommonConfig.current = true;
setCodexConfig(updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCodexCommonConfig.current = false;
}, 0);
};
// Codex: 处理通用配置片段变化
const handleCodexCommonConfigSnippetChange = (value: string) => {
const previousSnippet = codexCommonConfigSnippet.trim();
const sanitizedValue = value.trim();
setCodexCommonConfigSnippet(value);
if (!sanitizedValue) {
setCodexCommonConfigError("");
if (useCodexCommonConfig) {
const { updatedConfig } = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false
);
setCodexConfig(updatedConfig);
setUseCodexCommonConfig(false);
}
return;
}
// TOML 不需要验证 JSON 格式,直接更新
if (useCodexCommonConfig) {
const removeResult = updateTomlCommonConfigSnippet(
codexConfig,
previousSnippet,
false
);
const addResult = updateTomlCommonConfigSnippet(
removeResult.updatedConfig,
sanitizedValue,
true
);
if (addResult.error) {
setCodexCommonConfigError(addResult.error);
return;
}
// 标记正在通过通用配置更新
isUpdatingFromCodexCommonConfig.current = true;
setCodexConfig(addResult.updatedConfig);
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCodexCommonConfig.current = false;
}, 0);
}
// 保存 Codex 通用配置到 localStorage
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
CODEX_COMMON_CONFIG_STORAGE_KEY,
sanitizedValue
);
} catch {
// ignore localStorage 写入失败
}
}
};
// Codex: 处理 config 变化
const handleCodexConfigChange = (value: string) => {
if (!isUpdatingFromCodexCommonConfig.current) {
const hasCommon = hasTomlCommonConfigSnippet(
value,
codexCommonConfigSnippet
);
setUseCodexCommonConfig(hasCommon);
}
setCodexConfig(value);
if (!isUpdatingCodexBaseUrlRef.current) {
const extracted = extractCodexBaseUrl(value) || "";
if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted);
}
}
};
// 根据当前配置决定是否展示 API Key 输入框
// 自定义模式(-1)也需要显示 API Key 输入框
const showApiKey =
selectedPreset !== null ||
(!showPresets && hasApiKeyField(formData.settingsConfig));
const normalizedCategory = category ?? initialData?.category;
const shouldShowSpeedTest =
normalizedCategory === "third_party" || normalizedCategory === "custom";
const selectedTemplatePreset =
!isCodex &&
selectedPreset !== null &&
selectedPreset >= 0 &&
selectedPreset < providerPresets.length
? providerPresets[selectedPreset]
: null;
const templateValueEntries: Array<[string, TemplateValueConfig]> =
selectedTemplatePreset?.templateValues
? (Object.entries(selectedTemplatePreset.templateValues) as Array<
[string, TemplateValueConfig]
>)
: [];
// 判断当前选中的预设是否是官方
const isOfficialPreset =
(selectedPreset !== null &&
selectedPreset >= 0 &&
(providerPresets[selectedPreset]?.isOfficial === true ||
providerPresets[selectedPreset]?.category === "official")) ||
category === "official";
// 判断当前选中的预设是否是 Kimi
const isKimiPreset =
selectedPreset !== null &&
selectedPreset >= 0 &&
providerPresets[selectedPreset]?.name?.includes("Kimi");
// 判断当前编辑的是否是 Kimi 提供商(通过名称或配置判断)
const isEditingKimi =
initialData &&
(formData.name.includes("Kimi") ||
formData.name.includes("kimi") ||
(formData.settingsConfig.includes("api.moonshot.cn") &&
formData.settingsConfig.includes("ANTHROPIC_MODEL")));
// 综合判断是否应该显示 Kimi 模型选择器
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
const claudeSpeedTestEndpoints = useMemo<EndpointCandidate[]>(() => {
if (isCodex) return [];
const map = new Map<string, EndpointCandidate>();
const add = (url?: string) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized });
};
if (baseUrl) {
add(baseUrl);
}
if (initialData && typeof initialData.settingsConfig === "object") {
const envUrl = (initialData.settingsConfig as any)?.env
?.ANTHROPIC_BASE_URL;
if (typeof envUrl === "string") {
add(envUrl);
}
}
if (
selectedPreset !== null &&
selectedPreset >= 0 &&
selectedPreset < providerPresets.length
) {
const preset = providerPresets[selectedPreset];
const presetEnv = (preset.settingsConfig as any)?.env?.ANTHROPIC_BASE_URL;
if (typeof presetEnv === "string") {
add(presetEnv);
}
// 合并预设内置的请求地址候选
if (Array.isArray((preset as any).endpointCandidates)) {
((preset as any).endpointCandidates as string[]).forEach((u) => add(u));
}
}
return Array.from(map.values());
}, [isCodex, baseUrl, initialData, selectedPreset]);
const codexSpeedTestEndpoints = useMemo<EndpointCandidate[]>(() => {
if (!isCodex) return [];
const map = new Map<string, EndpointCandidate>();
const add = (url?: string) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized });
};
if (codexBaseUrl) {
add(codexBaseUrl);
}
const initialCodexConfig =
initialData && typeof initialData.settingsConfig?.config === "string"
? (initialData.settingsConfig as any).config
: "";
const existing = extractCodexBaseUrl(initialCodexConfig);
if (existing) {
add(existing);
}
if (
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
selectedCodexPreset < codexProviderPresets.length
) {
const preset = codexProviderPresets[selectedCodexPreset];
const presetBase = extractCodexBaseUrl(preset?.config || "");
if (presetBase) {
add(presetBase);
}
// 合并预设内置的请求地址候选
if (Array.isArray((preset as any)?.endpointCandidates)) {
((preset as any).endpointCandidates as string[]).forEach((u) => add(u));
}
}
return Array.from(map.values());
}, [isCodex, codexBaseUrl, initialData, selectedCodexPreset]);
// 判断是否显示"获取 API Key"链接(国产官方、聚合站和第三方显示)
const shouldShowApiKeyLink =
!isCodex &&
!isOfficialPreset &&
(category === "cn_official" ||
category === "aggregator" ||
category === "third_party" ||
(selectedPreset !== null &&
selectedPreset >= 0 &&
(providerPresets[selectedPreset]?.category === "cn_official" ||
providerPresets[selectedPreset]?.category === "aggregator" ||
providerPresets[selectedPreset]?.category === "third_party")));
// 获取当前供应商的网址
const getCurrentWebsiteUrl = () => {
if (selectedPreset !== null && selectedPreset >= 0) {
const preset = providerPresets[selectedPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl,其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
// 获取 Codex 当前供应商的网址
const getCurrentCodexWebsiteUrl = () => {
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
const preset = codexProviderPresets[selectedCodexPreset];
if (!preset) return "";
// 仅第三方供应商使用专用 apiKeyUrl,其余使用官网地址
return preset.category === "third_party"
? preset.apiKeyUrl || preset.websiteUrl || ""
: preset.websiteUrl || "";
}
return formData.websiteUrl || "";
};
// Codex: 控制显示 API Key 与官方标记
const getCodexAuthApiKey = (authString: string): string => {
try {
const auth = JSON.parse(authString || "{}");
return typeof auth.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY : "";
} catch {
return "";
}
};
// 自定义模式(-1)不显示独立的 API Key 输入框
const showCodexApiKey =
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
// 不再渲染分类介绍组件,避免造成干扰
const isCodexOfficialPreset =
(selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
(codexProviderPresets[selectedCodexPreset]?.isOfficial === true ||
codexProviderPresets[selectedCodexPreset]?.category === "official")) ||
category === "official";
// 判断是否显示 Codex 的"获取 API Key"链接(国产官方、聚合站和第三方显示)
const shouldShowCodexApiKeyLink =
isCodex &&
!isCodexOfficialPreset &&
(category === "cn_official" ||
category === "aggregator" ||
category === "third_party" ||
(selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
(codexProviderPresets[selectedCodexPreset]?.category ===
"cn_official" ||
codexProviderPresets[selectedCodexPreset]?.category ===
"aggregator" ||
codexProviderPresets[selectedCodexPreset]?.category ===
"third_party")));
// 处理模型输入变化,自动更新 JSON 配置
const handleModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setClaudeModel(value);
} else {
setClaudeSmallFastModel(value);
}
// 更新 JSON 配置
try {
const currentConfig = formData.settingsConfig
? JSON.parse(formData.settingsConfig)
: { env: {} };
if (!currentConfig.env) currentConfig.env = {};
if (value.trim()) {
currentConfig.env[field] = value.trim();
} else {
delete currentConfig.env[field];
}
updateSettingsConfigValue(JSON.stringify(currentConfig, null, 2));
} catch (err) {
// 如果 JSON 解析失败,不做处理
}
};
// Kimi 模型选择处理函数
const handleKimiModelChange = (
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
value: string
) => {
if (field === "ANTHROPIC_MODEL") {
setKimiAnthropicModel(value);
} else {
setKimiAnthropicSmallFastModel(value);
}
// 更新配置 JSON
try {
const currentConfig = JSON.parse(formData.settingsConfig || "{}");
if (!currentConfig.env) currentConfig.env = {};
currentConfig.env[field] = value;
const updatedConfigString = JSON.stringify(currentConfig, null, 2);
updateSettingsConfigValue(updatedConfigString);
} catch (err) {
console.error("更新 Kimi 模型配置失败:", err);
}
};
// 初始时从配置中同步 API Key(编辑模式)
useEffect(() => {
if (!initialData) return;
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig)
);
if (parsedKey) setApiKey(parsedKey);
}, [initialData]);
// 支持按下 ESC 关闭弹窗
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// 若有子弹窗(端点测速/模板向导)处于打开状态,则交由子弹窗自身处理,避免级联关闭
if (
isEndpointModalOpen ||
isCodexEndpointModalOpen ||
isCodexTemplateModalOpen
) {
return;
}
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [
onClose,
isEndpointModalOpen,
isCodexEndpointModalOpen,
isCodexTemplateModalOpen,
]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
isLinux() ? "" : " backdrop-blur-sm"
}`}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-auto p-6 space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
<AlertCircle
size={20}
className="text-red-500 dark:text-red-400 flex-shrink-0"
/>
<p className="text-red-500 dark:text-red-400 text-sm font-medium">
{error}
</p>
</div>
)}
{showPresets && !isCodex && (
<PresetSelector
presets={providerPresets}
selectedIndex={selectedPreset}
onSelectPreset={(index) =>
applyPreset(providerPresets[index], index)
}
onCustomClick={handleCustomClick}
/>
)}
{showPresets && isCodex && (
<PresetSelector
presets={codexProviderPresets}
selectedIndex={selectedCodexPreset}
onSelectPreset={(index) =>
applyCodexPreset(codexProviderPresets[index], index)
}
onCustomClick={handleCodexCustomClick}
renderCustomDescription={() => (
<>
<button
type="button"
onClick={() => setIsCodexTemplateModalOpen(true)}
className="text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors ml-1"
>
使
</button>
</>
)}
/>
)}
<div className="space-y-2">
<label
htmlFor="name"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
*
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="例如:Anthropic 官方"
required
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
</div>
<div className="space-y-2">
<label
htmlFor="websiteUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<input
type="url"
id="websiteUrl"
name="websiteUrl"
value={formData.websiteUrl}
onChange={handleChange}
placeholder="https://example.com(可选)"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
</div>
{!isCodex && showApiKey && (
<div className="space-y-1">
<ApiKeyInput
value={apiKey}
onChange={handleApiKeyChange}
required={!isOfficialPreset}
placeholder={
isOfficialPreset
? "官方登录无需填写 API Key,直接保存即可"
: shouldShowKimiSelector
? "填写后可获取模型列表"
: "只需要填这里,下方配置会自动填充"
}
disabled={isOfficialPreset}
/>
{shouldShowApiKeyLink && getCurrentWebsiteUrl() && (
<div className="-mt-1 pl-1">
<a
href={getCurrentWebsiteUrl()}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
API Key
</a>
</div>
)}
</div>
)}
{!isCodex &&
selectedTemplatePreset &&
templateValueEntries.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
- {selectedTemplatePreset.name.trim()} *
</h3>
<div className="space-y-4">
{templateValueEntries.map(([key, config]) => (
<div key={key} className="space-y-2">
<label className="sr-only" htmlFor={`template-${key}`}>
{config.label}
</label>
<input
id={`template-${key}`}
type="text"
required
placeholder={`${config.label} *`}
value={
templateValues[key]?.editorValue ??
config.editorValue ??
config.defaultValue ??
""
}
onChange={(e) => {
const newValue = e.target.value;
setTemplateValues((prev) => {
const prevEntry = prev[key];
const nextEntry: TemplateValueConfig = {
...config,
...(prevEntry ?? {}),
editorValue: newValue,
};
const nextValues: TemplateValueMap = {
...prev,
[key]: nextEntry,
};
if (selectedTemplatePreset) {
try {
const configString =
applyTemplateValuesToConfigString(
selectedTemplatePreset.settingsConfig,
formData.settingsConfig,
nextValues
);
setFormData((prevForm) => ({
...prevForm,
settingsConfig: configString,
}));
setSettingsConfigError(
validateSettingsConfig(configString)
);
} catch (err) {
console.error("更新模板值失败:", err);
}
}
return nextValues;
});
}}
aria-label={config.label}
autoComplete="off"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
))}
</div>
</div>
)}
{!isCodex && shouldShowSpeedTest && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="baseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<button
type="button"
onClick={() => setIsEndpointModalOpen(true)}
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"
>
<Zap className="h-3.5 w-3.5" />
</button>
</div>
<input
type="url"
id="baseUrl"
value={baseUrl}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder="https://your-api-endpoint.com"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
<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">
💡 Claude API
</p>
</div>
</div>
)}
{/* 端点测速弹窗 - Claude */}
{!isCodex && shouldShowSpeedTest && isEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={baseUrl}
onChange={handleBaseUrlChange}
initialEndpoints={claudeSpeedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => setIsEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}
{!isCodex && shouldShowKimiSelector && (
<KimiModelSelector
apiKey={apiKey}
anthropicModel={kimiAnthropicModel}
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
onModelChange={handleKimiModelChange}
disabled={isOfficialPreset}
/>
)}
{isCodex && showCodexApiKey && (
<div className="space-y-1">
<ApiKeyInput
id="codexApiKey"
label="API Key"
value={codexApiKey}
onChange={handleCodexApiKeyChange}
placeholder={
isCodexOfficialPreset
? "官方无需填写 API Key,直接保存即可"
: "只需要填这里,下方 auth.json 会自动填充"
}
disabled={isCodexOfficialPreset}
required={
selectedCodexPreset !== null &&
selectedCodexPreset >= 0 &&
!isCodexOfficialPreset
}
/>
{shouldShowCodexApiKeyLink && getCurrentCodexWebsiteUrl() && (
<div className="-mt-1 pl-1">
<a
href={getCurrentCodexWebsiteUrl()}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
>
API Key
</a>
</div>
)}
</div>
)}
{isCodex && shouldShowSpeedTest && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="codexBaseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<button
type="button"
onClick={() => setIsCodexEndpointModalOpen(true)}
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"
>
<Zap className="h-3.5 w-3.5" />
</button>
</div>
<input
type="url"
id="codexBaseUrl"
value={codexBaseUrl}
onChange={(e) => handleCodexBaseUrlChange(e.target.value)}
placeholder="https://your-api-endpoint.com/v1"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
</div>
)}
{/* 端点测速弹窗 - Codex */}
{isCodex && shouldShowSpeedTest && isCodexEndpointModalOpen && (
<EndpointSpeedTest
appType={appType}
providerId={initialData?.id}
value={codexBaseUrl}
onChange={handleCodexBaseUrlChange}
initialEndpoints={codexSpeedTestEndpoints}
visible={isCodexEndpointModalOpen}
onClose={() => setIsCodexEndpointModalOpen(false)}
onCustomEndpointsChange={setDraftCustomEndpoints}
/>
)}
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
<CodexConfigEditor
authValue={codexAuth}
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={handleCodexConfigChange}
onAuthBlur={() => {
try {
const auth = JSON.parse(codexAuth || "{}");
const key =
typeof auth.OPENAI_API_KEY === "string"
? auth.OPENAI_API_KEY
: "";
setCodexApiKey(key);
} catch {
// ignore
}
}}
useCommonConfig={useCodexCommonConfig}
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={
handleCodexCommonConfigSnippetChange
}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
isCustomMode={selectedCodexPreset === -1}
onWebsiteUrlChange={(url) => {
setFormData((prev) => ({
...prev,
websiteUrl: url,
}));
}}
onNameChange={(name) => {
setFormData((prev) => ({
...prev,
name,
}));
}}
isTemplateModalOpen={isCodexTemplateModalOpen}
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
/>
) : (
<>
{/* 可选的模型配置输入框 - 仅在非官方且非 Kimi 时显示 */}
{!isOfficialPreset && !shouldShowKimiSelector && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label
htmlFor="anthropicModel"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
()
</label>
<input
type="text"
id="anthropicModel"
value={claudeModel}
onChange={(e) =>
handleModelChange("ANTHROPIC_MODEL", e.target.value)
}
placeholder="例如: GLM-4.5"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
</div>
<div className="space-y-2">
<label
htmlFor="anthropicSmallFastModel"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
()
</label>
<input
type="text"
id="anthropicSmallFastModel"
value={claudeSmallFastModel}
onChange={(e) =>
handleModelChange(
"ANTHROPIC_SMALL_FAST_MODEL",
e.target.value
)
}
placeholder="例如: GLM-4.5-Air"
autoComplete="off"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"
/>
</div>
</div>
<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">
💡 使
</p>
</div>
</div>
)}
<ClaudeConfigEditor
value={formData.settingsConfig}
onChange={(value) =>
handleChange({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
configError={settingsConfigError}
/>
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
>
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
>
<Save className="w-4 h-4" />
{submitText}
</button>
</div>
</form>
</div>
</div>
);
};
export default ProviderForm;