import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Zap, Loader2, Plus, X, AlertCircle, Save } from "lucide-react"; import type { AppType } from "@/lib/api"; import { vscodeApi } from "@/lib/api/vscode"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import type { CustomEndpoint, EndpointCandidate } from "@/types"; // 端点测速超时配置(秒) const ENDPOINT_TIMEOUT_SECS = { codex: 12, claude: 8, } as const; interface TestResult { url: string; latency: number | null; status?: number; error?: string | null; } 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(); 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 = ({ appType, providerId, value, onChange, initialEndpoints, visible = true, onClose, onCustomEndpointsChange, }) => { const { t } = useTranslation(); const [entries, setEntries] = useState(() => buildInitialEntries(initialEndpoints, value), ); const [customUrl, setCustomUrl] = useState(""); const [addError, setAddError] = useState(null); const [autoSelect, setAutoSelect] = useState(true); const [isTesting, setIsTesting] = useState(false); const [lastError, setLastError] = useState(null); const normalizedSelected = normalizeEndpointUrl(value); const hasEndpoints = entries.length > 0; // 加载保存的自定义端点(按正在编辑的供应商) useEffect(() => { let cancelled = false; const loadCustomEndpoints = async () => { try { if (!providerId) return; const customEndpoints = await vscodeApi.getCustomEndpoints( appType, providerId, ); // 检查是否已取消 if (cancelled) return; const candidates: EndpointCandidate[] = customEndpoints.map( (ep: CustomEndpoint) => ({ url: ep.url, isCustom: true, }), ); setEntries((prev) => { const map = new Map(); // 先添加现有端点 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) { if (!cancelled) { console.error(t("endpointTest.loadEndpointsFailed"), error); } } }; if (visible) { loadCustomEndpoints(); } return () => { cancelled = true; }; }, [appType, visible, providerId, t]); useEffect(() => { setEntries((prev) => { const map = new Map(); 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: TestResult, b: TestResult) => { 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 = t("endpointTest.enterValidUrl"); } let parsed: URL | null = null; if (!errorMsg) { try { parsed = new URL(candidate); } catch { errorMsg = t("endpointTest.invalidUrlFormat"); } } // 明确只允许 http: 和 https: const allowedProtocols = ['http:', 'https:']; if (!errorMsg && parsed && !allowedProtocols.includes(parsed.protocol)) { errorMsg = t("endpointTest.onlyHttps"); } let sanitized = ""; if (!errorMsg && parsed) { sanitized = normalizeEndpointUrl(parsed.toString()); // 使用当前 entries 做去重校验,避免依赖可能过期的 addError const isDuplicate = entries.some((entry) => entry.url === sanitized); if (isDuplicate) { errorMsg = t("endpointTest.urlExists"); } } if (errorMsg) { setAddError(errorMsg); return; } setAddError(null); // 保存到后端 try { if (providerId) { await vscodeApi.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 || t("endpointTest.saveFailed")); console.error(t("endpointTest.addEndpointFailed"), error); } }, [ customUrl, entries, normalizedSelected, onChange, appType, providerId, t, ]); const handleRemoveEndpoint = useCallback( async (entry: EndpointEntry) => { // 清空之前的错误提示 setLastError(null); // 如果有 providerId,尝试从后端删除 if (entry.isCustom && providerId) { try { await vscodeApi.removeCustomEndpoint(appType, providerId, entry.url); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); // 只有"端点不存在"时才允许删除本地条目 if (errorMsg.includes('not found') || errorMsg.includes('does not exist') || errorMsg.includes('不存在')) { console.warn(t('endpointTest.removeEndpointFailed'), errorMsg); // 继续删除本地条目 } else { // 其他错误:显示错误提示,阻止删除 setLastError(t('endpointTest.removeFailed', { error: errorMsg })); 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, t], ); const runSpeedTest = useCallback(async () => { const urls = entries.map((entry) => entry.url); if (urls.length === 0) { setLastError(t("endpointTest.pleaseAddEndpoint")); return; } setIsTesting(true); setLastError(null); // 清空所有延迟数据,显示 loading 状态 setEntries((prev) => prev.map((entry) => ({ ...entry, latency: null, status: undefined, error: null, })), ); try { const results = await vscodeApi.testApiEndpoints(urls, { timeoutSecs: ENDPOINT_TIMEOUT_SECS[appType], }); 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: t("endpointTest.noResult"), }; } 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 : `${t("endpointTest.testFailed", { error: String(error) })}`; setLastError(message); } finally { setIsTesting(false); } }, [entries, autoSelect, appType, normalizedSelected, onChange, t]); const handleSelect = useCallback( async (url: string) => { if (!url || url === normalizedSelected) return; // 更新最后使用时间(对自定义端点) const entry = entries.find((e) => e.url === url); if (entry?.isCustom && providerId) { try { await vscodeApi.updateEndpointLastUsed(appType, providerId, url); } catch (error) { console.error(t("endpointTest.updateLastUsedFailed"), error); } } onChange(url); }, [normalizedSelected, onChange, appType, entries, providerId, t], ); return ( !open && onClose()}> {t("endpointTest.title")} {/* Content */}
{/* 测速控制栏 */}
{entries.length} {t("endpointTest.endpoints")}
{/* 添加输入 */}
setCustomUrl(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); handleAddEndpoint(); } }} className="flex-1" />
{addError && (
{addError}
)}
{/* 端点列表 */} {hasEndpoints ? (
{sortedEntries.map((entry) => { const isSelected = normalizedSelected === entry.url; const latency = entry.latency; return (
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-border-default bg-white hover:border-border-default hover:bg-gray-50 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-800" }`} >
{/* 选择指示器 */}
{/* 内容 */}
{entry.url}
{/* 右侧信息 */}
{latency !== null ? (
{latency}ms
) : isTesting ? ( ) : entry.error ? (
{t("endpointTest.failed")}
) : (
)}
); })}
) : (
{t("endpointTest.noEndpoints")}
)} {/* 错误提示 */} {lastError && (
{lastError}
)}
); }; export default EndpointSpeedTest;