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.
This commit is contained in:
Jason
2025-10-04 18:04:40 +08:00
parent e0908701b4
commit 4fc76200e8
10 changed files with 1014 additions and 135 deletions

View File

@@ -0,0 +1,478 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Zap, Loader2, Plus, Trash2, AlertCircle, Check } from "lucide-react";
import type { AppType } from "../../lib/tauri-api";
export interface EndpointCandidate {
id?: string;
url: string;
label?: string;
isCustom?: boolean;
}
interface EndpointSpeedTestProps {
appType: AppType;
value: string;
onChange: (url: string) => void;
initialEndpoints: EndpointCandidate[];
visible?: boolean;
}
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)) {
const existing = map.get(sanitized)!;
if (candidate.label && candidate.label !== existing.label) {
map.set(sanitized, { ...existing, label: candidate.label });
}
return;
}
const index = map.size;
const label =
candidate.label ??
(candidate.isCustom
? `自定义 ${index + 1}`
: index === 0
? "默认地址"
: `候选 ${index + 1}`);
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
label,
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, label: "当前地址", isCustom: true });
}
return Array.from(map.values());
};
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
appType,
value,
onChange,
initialEndpoints,
visible = true,
}) => {
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(() => {
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) {
if (candidate.label && candidate.label !== existing.label) {
map.set(sanitized, { ...existing, label: candidate.label });
changed = true;
}
return;
}
const index = map.size;
const label =
candidate.label ??
(candidate.isCustom
? `自定义 ${index + 1}`
: index === 0
? "默认地址"
: `候选 ${index + 1}`);
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
label,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected) {
const existing = map.get(normalizedSelected);
if (existing) {
if (existing.label !== "当前地址") {
map.set(normalizedSelected, {
...existing,
label: existing.isCustom ? existing.label : "当前地址",
});
changed = true;
}
} else {
mergeCandidate({ url: normalizedSelected, label: "当前地址", isCustom: true });
}
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
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(() => {
const candidate = customUrl.trim();
setAddError(null);
if (!candidate) {
setAddError("请输入有效的 URL");
return;
}
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
setAddError("URL 格式不正确,请确认包含 http(s) 前缀");
return;
}
if (!parsed.protocol.startsWith("http")) {
setAddError("仅支持 HTTP/HTTPS 地址");
return;
}
const sanitized = normalizeEndpointUrl(parsed.toString());
setEntries((prev) => {
if (prev.some((entry) => entry.url === sanitized)) {
setAddError("该地址已存在");
return prev;
}
const customCount = prev.filter((entry) => entry.isCustom).length;
return [
...prev,
{
id: randomId(),
url: sanitized,
label: `自定义 ${customCount + 1}`,
isCustom: true,
latency: null,
status: undefined,
error: null,
},
];
});
if (!normalizedSelected) {
onChange(sanitized);
}
setCustomUrl("");
}, [customUrl, normalizedSelected, onChange]);
const handleRemoveEndpoint = useCallback(
(entry: EndpointEntry) => {
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],
);
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(
(url: string) => {
if (!url || url === normalizedSelected) return;
onChange(url);
},
[normalizedSelected, onChange],
);
if (!visible) {
return null;
}
return (
<div className="mt-4 space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200">
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
className="rounded border-gray-300 text-blue-500 focus:ring-blue-400"
/>
</label>
<button
type="button"
onClick={runSpeedTest}
disabled={isTesting || entries.length === 0}
className="flex items-center gap-2 rounded-md bg-blue-500 px-3 py-1.5 text-sm text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-60"
>
{isTesting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Zap className="h-4 w-4" />
</>
)}
</button>
</div>
</div>
{hasEndpoints ? (
<div className="space-y-2">
{sortedEntries.map((entry) => {
const isSelected = normalizedSelected === entry.url;
const latency = entry.latency;
const statusBadge =
latency !== null
? latency <= 100
? "text-green-600 dark:text-green-400"
: latency <= 300
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400"
: "text-gray-500 dark:text-gray-400";
return (
<div
key={entry.id}
className={`flex items-start justify-between gap-2 rounded-lg border px-3 py-2 text-sm transition ${
isSelected
? "border-green-400 bg-green-50 dark:border-green-500 dark:bg-green-900/30"
: "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
}`}
>
<label className="flex flex-1 cursor-pointer items-start gap-2">
<input
type="radio"
name="endpoint-speedtest"
checked={isSelected}
onChange={() => handleSelect(entry.url)}
className="mt-1 h-4 w-4 border-gray-300 text-green-500 focus:ring-green-400"
/>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800 dark:text-gray-100">
{entry.label || "候选节点"}
</span>
{isSelected && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<Check className="h-3 w-3" />
</span>
)}
</div>
<span className="break-all text-xs text-gray-500 dark:text-gray-400">
{entry.url}
</span>
</div>
</label>
<div className="flex items-center gap-3">
<div className="text-xs font-mono">
{latency !== null ? (
<span className={statusBadge}>{latency} ms</span>
) : isTesting ? (
<span className="text-gray-400"></span>
) : entry.error ? (
<span className="flex items-center gap-1 text-red-500">
<AlertCircle className="h-3 w-3" />
</span>
) : (
<span className="text-gray-400"></span>
)}
</div>
{entry.isCustom && (
<button
type="button"
onClick={() => handleRemoveEndpoint(entry)}
className="rounded-md p-1 text-red-500 transition hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30"
title="删除该地址"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-4 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
</div>
)}
<div>
<div className="flex gap-2">
<input
type="url"
value={customUrl}
placeholder="https://example.com/claude"
onChange={(event) => setCustomUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddEndpoint();
}
}}
className="flex-1 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
/>
<button
type="button"
onClick={handleAddEndpoint}
className="flex items-center gap-1 rounded-md bg-green-500 px-3 py-1.5 text-sm text-white transition hover:bg-green-600"
>
<Plus className="h-4 w-4" />
</button>
</div>
{addError && (
<p className="mt-1 text-xs text-red-500">{addError}</p>
)}
</div>
{lastError && (
<p className="text-xs text-red-500">
<AlertCircle className="mr-1 inline h-3 w-3 align-middle" />
{lastError}
</p>
)}
</div>
);
};
export default EndpointSpeedTest;