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>
This commit is contained in:
YoVinchen
2025-10-07 19:14:32 +08:00
committed by GitHub
parent 3ad11acdb2
commit ca488cf076
15 changed files with 1710 additions and 287 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { Provider, ProviderCategory } from "../types";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Provider, ProviderCategory, CustomEndpoint } from "../types";
import { AppType } from "../lib/tauri-api";
import {
updateCommonConfigSnippet,
@@ -11,6 +11,8 @@ import {
hasTomlCommonConfigSnippet,
validateJsonConfig,
applyTemplateValues,
extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig,
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import type { TemplateValueConfig } from "../config/providerPresets";
@@ -24,8 +26,11 @@ import ApiKeyInput from "./ProviderForm/ApiKeyInput";
import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
import { X, AlertCircle, Save } from "lucide-react";
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>;
@@ -36,11 +41,11 @@ const collectTemplatePaths = (
source: unknown,
templateKeys: string[],
currentPath: TemplatePath = [],
acc: TemplatePath[] = [],
acc: TemplatePath[] = []
): TemplatePath[] => {
if (typeof source === "string") {
const hasPlaceholder = templateKeys.some((key) =>
source.includes(`\${${key}}`),
source.includes(`\${${key}}`)
);
if (hasPlaceholder) {
acc.push([...currentPath]);
@@ -50,14 +55,14 @@ const collectTemplatePaths = (
if (Array.isArray(source)) {
source.forEach((item, index) =>
collectTemplatePaths(item, templateKeys, [...currentPath, index], acc),
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),
collectTemplatePaths(value, templateKeys, [...currentPath, key], acc)
);
}
@@ -76,7 +81,7 @@ const getValueAtPath = (source: any, path: TemplatePath) => {
const setValueAtPath = (
target: any,
path: TemplatePath,
value: unknown,
value: unknown
): any => {
if (path.length === 0) {
return value;
@@ -114,7 +119,7 @@ const setValueAtPath = (
const applyTemplateValuesToConfigString = (
presetConfig: any,
currentConfigString: string,
values: TemplateValueMap,
values: TemplateValueMap
) => {
const replacedConfig = applyTemplateValues(presetConfig, values);
const templateKeys = Object.keys(values);
@@ -204,15 +209,25 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
// 模板变量状态
const [templateValues, setTemplateValues] =
useState<Record<string, TemplateValueConfig>>({});
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
@@ -223,8 +238,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuthError(validateCodexAuth(value));
};
const setCodexConfig = (value: string) => {
setCodexConfigState(value);
const setCodexConfig = (value: string | ((prev: string) => string)) => {
setCodexConfigState((prev) =>
typeof value === "function"
? (value as (input: string) => string)(prev)
: value
);
};
const setCodexCommonConfigSnippet = (value: string) => {
@@ -238,6 +257,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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") {
@@ -292,6 +315,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
});
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>(
@@ -436,6 +461,43 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}, [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;
@@ -543,13 +605,31 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
}
onSubmit({
// 构造基础提交数据
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 = (
@@ -692,7 +772,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
...config,
editorValue: config.editorValue
? config.editorValue
: config.defaultValue ?? "",
: (config.defaultValue ?? ""),
},
])
);
@@ -721,7 +801,6 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 清空 API Key 输入框,让用户重新输入
setApiKey("");
setBaseUrl(""); // 清空基础 URL
// 同步通用配置状态
const hasCommon = hasCommonConfigSnippet(configString, commonConfigSnippet);
@@ -734,6 +813,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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")) {
@@ -745,6 +829,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
} else {
setClaudeModel("");
setClaudeSmallFastModel("");
setBaseUrl("");
}
}
};
@@ -791,6 +876,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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,
@@ -828,6 +917,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setCodexAuth(JSON.stringify(customAuth, null, 2));
setCodexConfig(customConfig);
setCodexApiKey("");
setCodexBaseUrl("https://your-api-endpoint.com/v1");
setCategory("custom");
};
@@ -851,21 +941,42 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 处理基础 URL 变化
const handleBaseUrlChange = (url: string) => {
setBaseUrl(url);
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 = url.trim();
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);
@@ -971,6 +1082,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
setUseCodexCommonConfig(hasCommon);
}
setCodexConfig(value);
if (!isUpdatingCodexBaseUrlRef.current) {
const extracted = extractCodexBaseUrl(value) || "";
if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted);
}
}
};
// 根据当前配置决定是否展示 API Key 输入框
@@ -979,6 +1096,10 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
selectedPreset !== null ||
(!showPresets && hasApiKeyField(formData.settingsConfig));
const normalizedCategory = category ?? initialData?.category;
const shouldShowSpeedTest =
normalizedCategory === "third_party" || normalizedCategory === "custom";
const selectedTemplatePreset =
!isCodex &&
selectedPreset !== null &&
@@ -989,9 +1110,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const templateValueEntries: Array<[string, TemplateValueConfig]> =
selectedTemplatePreset?.templateValues
? (Object.entries(
selectedTemplatePreset.templateValues
) as Array<[string, TemplateValueConfig]>)
? (Object.entries(selectedTemplatePreset.templateValues) as Array<
[string, TemplateValueConfig]
>)
: [];
// 判断当前选中的预设是否是官方
@@ -1019,8 +1140,88 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 综合判断是否应该显示 Kimi 模型选择器
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
// 判断是否显示基础 URL 输入框(仅自定义模式显示)
const showBaseUrlInput = selectedPreset === -1 && !isCodex;
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 =
@@ -1168,13 +1369,26 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
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]);
}, [
onClose,
isEndpointModalOpen,
isCodexEndpointModalOpen,
isCodexTemplateModalOpen,
]);
return (
<div
@@ -1324,83 +1538,95 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</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,
};
{!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);
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>
))}
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>
</div>
)}
)}
{/* 基础 URL 输入框 - 仅在自定义模式下显示 */}
{!isCodex && showBaseUrlInput && (
{!isCodex && shouldShowSpeedTest && (
<div className="space-y-2">
<label
htmlFor="baseUrl"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
</label>
<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"
@@ -1418,6 +1644,20 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</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}
@@ -1462,6 +1702,50 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
</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