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:
@@ -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
|
||||
|
||||
602
src/components/ProviderForm/EndpointSpeedTest.tsx
Normal file
602
src/components/ProviderForm/EndpointSpeedTest.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Zap, Loader2, Plus, X, AlertCircle } from "lucide-react";
|
||||
import { isLinux } from "../../lib/platform";
|
||||
|
||||
import type { AppType } from "../../lib/tauri-api";
|
||||
|
||||
export interface EndpointCandidate {
|
||||
id?: string;
|
||||
url: string;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
interface EndpointSpeedTestProps {
|
||||
appType: AppType;
|
||||
providerId?: string;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface EndpointEntry extends EndpointCandidate {
|
||||
id: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const normalizeEndpointUrl = (url: string): string =>
|
||||
url.trim().replace(/\/+$/, "");
|
||||
|
||||
const buildInitialEntries = (
|
||||
candidates: EndpointCandidate[],
|
||||
selected: string,
|
||||
): EndpointEntry[] => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
const addCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
|
||||
if (!sanitized) return;
|
||||
if (map.has(sanitized)) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
candidates.forEach(addCandidate);
|
||||
|
||||
const selectedUrl = normalizeEndpointUrl(selected);
|
||||
if (selectedUrl && !map.has(selectedUrl)) {
|
||||
addCandidate({ url: selectedUrl, isCustom: true });
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
appType,
|
||||
providerId,
|
||||
value,
|
||||
onChange,
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
|
||||
buildInitialEntries(initialEndpoints, value),
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const normalizedSelected = normalizeEndpointUrl(value);
|
||||
|
||||
const hasEndpoints = entries.length > 0;
|
||||
|
||||
// 加载保存的自定义端点(按正在编辑的供应商)
|
||||
useEffect(() => {
|
||||
const loadCustomEndpoints = async () => {
|
||||
try {
|
||||
if (!providerId) return;
|
||||
const customEndpoints = await window.api.getCustomEndpoints(
|
||||
appType,
|
||||
providerId,
|
||||
);
|
||||
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
|
||||
url: ep.url,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
|
||||
// 先添加现有端点
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
// 合并自定义端点
|
||||
candidates.forEach((candidate) => {
|
||||
const sanitized = normalizeEndpointUrl(candidate.url);
|
||||
if (sanitized && !map.has(sanitized)) {
|
||||
map.set(sanitized, {
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载自定义端点失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
loadCustomEndpoints();
|
||||
}
|
||||
}, [appType, visible, providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
||||
const mergeCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url
|
||||
? normalizeEndpointUrl(candidate.url)
|
||||
: "";
|
||||
if (!sanitized) return;
|
||||
const existing = map.get(sanitized);
|
||||
if (existing) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
changed = true;
|
||||
};
|
||||
|
||||
initialEndpoints.forEach(mergeCandidate);
|
||||
|
||||
if (normalizedSelected && !map.has(normalizedSelected)) {
|
||||
mergeCandidate({ url: normalizedSelected, isCustom: true });
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}, [initialEndpoints, normalizedSelected]);
|
||||
|
||||
// 将自定义端点变化透传给父组件(仅限 isCustom)
|
||||
useEffect(() => {
|
||||
if (!onCustomEndpointsChange) return;
|
||||
try {
|
||||
const customUrls = Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.filter((e) => e.isCustom)
|
||||
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
onCustomEndpointsChange(customUrls);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
// 仅在 entries 变化时同步
|
||||
}, [entries, onCustomEndpointsChange]);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return entries.slice().sort((a, b) => {
|
||||
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
|
||||
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
|
||||
if (aLatency === bLatency) {
|
||||
return a.url.localeCompare(b.url);
|
||||
}
|
||||
return aLatency - bLatency;
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const handleAddEndpoint = useCallback(
|
||||
async () => {
|
||||
const candidate = customUrl.trim();
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
if (!candidate) {
|
||||
errorMsg = "请输入有效的 URL";
|
||||
}
|
||||
|
||||
let parsed: URL | null = null;
|
||||
if (!errorMsg) {
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
errorMsg = "URL 格式不正确";
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
|
||||
errorMsg = "仅支持 HTTP/HTTPS";
|
||||
}
|
||||
|
||||
let sanitized = "";
|
||||
if (!errorMsg && parsed) {
|
||||
sanitized = normalizeEndpointUrl(parsed.toString());
|
||||
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
|
||||
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
||||
if (isDuplicate) {
|
||||
errorMsg = "该地址已存在";
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
setAddError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAddError(null);
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
if (providerId) {
|
||||
await window.api.addCustomEndpoint(appType, providerId, sanitized);
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
if (prev.some((e) => e.url === sanitized)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (!normalizedSelected) {
|
||||
onChange(sanitized);
|
||||
}
|
||||
|
||||
setCustomUrl("");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setAddError(message || "保存失败,请重试");
|
||||
console.error("添加自定义端点失败:", error);
|
||||
}
|
||||
},
|
||||
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
|
||||
);
|
||||
|
||||
const handleRemoveEndpoint = useCallback(
|
||||
async (entry: EndpointEntry) => {
|
||||
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
|
||||
if (entry.isCustom && providerId) {
|
||||
try {
|
||||
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
||||
} catch (error) {
|
||||
console.error("删除自定义端点失败:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
const next = prev.filter((item) => item.id !== entry.id);
|
||||
if (entry.url === normalizedSelected) {
|
||||
const fallback = next[0];
|
||||
onChange(fallback ? fallback.url : "");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[normalizedSelected, onChange, appType, providerId],
|
||||
);
|
||||
|
||||
const runSpeedTest = useCallback(async () => {
|
||||
const urls = entries.map((entry) => entry.url);
|
||||
if (urls.length === 0) {
|
||||
setLastError("请先添加端点");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
||||
setLastError("测速功能不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setLastError(null);
|
||||
|
||||
try {
|
||||
const results = await window.api.testApiEndpoints(urls, {
|
||||
timeoutSecs: appType === "codex" ? 12 : 8,
|
||||
});
|
||||
const resultMap = new Map(
|
||||
results.map((item) => [normalizeEndpointUrl(item.url), item]),
|
||||
);
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
const match = resultMap.get(entry.url);
|
||||
if (!match) {
|
||||
return {
|
||||
...entry,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: "未返回结果",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
latency:
|
||||
typeof match.latency === "number" ? Math.round(match.latency) : null,
|
||||
status: match.status,
|
||||
error: match.error ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (autoSelect) {
|
||||
const successful = results
|
||||
.filter((item) => typeof item.latency === "number" && item.latency !== null)
|
||||
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
|
||||
const best = successful[0];
|
||||
if (best && best.url && best.url !== normalizedSelected) {
|
||||
onChange(best.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : `测速失败: ${String(error)}`;
|
||||
setLastError(message);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (url: string) => {
|
||||
if (!url || url === normalizedSelected) return;
|
||||
|
||||
// 更新最后使用时间(对自定义端点)
|
||||
const entry = entries.find((e) => e.url === url);
|
||||
if (entry?.isCustom && providerId) {
|
||||
await window.api.updateEndpointLastUsed(appType, providerId, url);
|
||||
}
|
||||
|
||||
onChange(url);
|
||||
},
|
||||
[normalizedSelected, onChange, appType, entries, providerId],
|
||||
);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
请求地址管理
|
||||
</h3>
|
||||
<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={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{/* 测速控制栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{entries.length} 个端点
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
自动选择
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSpeedTest}
|
||||
disabled={isTesting || !hasEndpoints}
|
||||
className="flex h-7 items-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
测速中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
测速
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
placeholder="https://api.example.com"
|
||||
onChange={(event) => setCustomUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddEndpoint();
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEndpoint}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 端点列表 */}
|
||||
{hasEndpoints ? (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isSelected = normalizedSelected === entry.url;
|
||||
const latency = entry.latency;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => handleSelect(entry.url)}
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 选择指示器 */}
|
||||
<div
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
|
||||
isSelected
|
||||
? "bg-blue-500 dark:bg-blue-400"
|
||||
: "bg-gray-300 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||
{entry.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{latency !== null ? (
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{latency}ms
|
||||
</div>
|
||||
</div>
|
||||
) : isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : entry.error ? (
|
||||
<div className="text-xs text-gray-400">失败</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">—</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveEndpoint(entry);
|
||||
}}
|
||||
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
|
||||
暂无端点
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</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 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"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointSpeedTest;
|
||||
Reference in New Issue
Block a user