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 { AppId } from "@/lib/api"; import { vscodeApi } from "@/lib/api/vscode"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import type { CustomEndpoint, EndpointCandidate } from "@/types"; // 端点测速超时配置(秒) const ENDPOINT_TIMEOUT_SECS = { codex: 12, claude: 8, gemini: 8, // 新增 gemini } as const; interface TestResult { url: string; latency: number | null; status?: number; error?: string | null; } interface EndpointSpeedTestProps { appId: AppId; 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 = ({ appId, 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 [isSaving, setIsSaving] = useState(false); // 记录初始的自定义端点,用于对比变化 const [initialCustomUrls, setInitialCustomUrls] = useState>( new Set(), ); const normalizedSelected = normalizeEndpointUrl(value); const hasEndpoints = entries.length > 0; const isEditMode = Boolean(providerId); // 编辑模式有 providerId // 编辑模式:加载已保存的自定义端点 useEffect(() => { let cancelled = false; const loadCustomEndpoints = async () => { try { if (!providerId) return; // 新建模式不加载 const customEndpoints = await vscodeApi.getCustomEndpoints( appId, providerId, ); if (cancelled) return; const candidates: EndpointCandidate[] = customEndpoints.map( (ep: CustomEndpoint) => ({ url: ep.url, isCustom: true, }), ); // 记录初始的自定义端点 const customUrls = new Set( customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)), ); setInitialCustomUrls(customUrls); // 合并自定义端点与初始端点 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 (providerId) { loadCustomEndpoints(); } return () => { cancelled = true; }; }, [appId, providerId, t, initialEndpoints]); // 新建模式:将自定义端点变化透传给父组件(仅限 isCustom) // 编辑模式:不使用此回调,端点已通过 API 直接保存 useEffect(() => { if (!onCustomEndpointsChange || isEditMode) 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, onCustomEndpointsChange, isEditMode]); 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 做去重校验 const isDuplicate = entries.some((entry) => entry.url === sanitized); if (isDuplicate) { errorMsg = t("endpointTest.urlExists"); } } if (errorMsg) { setAddError(errorMsg); return; } setAddError(null); setLastError(null); // 更新本地状态(延迟保存,点击保存按钮时统一处理) 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(""); }, [customUrl, entries, normalizedSelected, onChange, t]); const handleRemoveEndpoint = useCallback( (entry: EndpointEntry) => { // 清空之前的错误提示 setLastError(null); // 更新本地状态(延迟保存,点击保存按钮时统一处理) 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(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[appId], }); 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, appId, normalizedSelected, onChange, t]); const handleSelect = useCallback( (url: string) => { if (!url || url === normalizedSelected) return; onChange(url); }, [normalizedSelected, onChange], ); // 保存端点变更 const handleSave = useCallback(async () => { // 编辑模式:对比初始端点和当前端点,批量保存变更 if (isEditMode && providerId) { setIsSaving(true); setLastError(null); try { // 获取当前的自定义端点 const currentCustomUrls = new Set( entries .filter((e) => e.isCustom) .map((e) => normalizeEndpointUrl(e.url)), ); // 找出新增的端点 const toAdd = Array.from(currentCustomUrls).filter( (url) => !initialCustomUrls.has(url), ); // 找出删除的端点 const toRemove = Array.from(initialCustomUrls).filter( (url) => !currentCustomUrls.has(url), ); // 批量添加 for (const url of toAdd) { await vscodeApi.addCustomEndpoint(appId, providerId, url); } // 批量删除 for (const url of toRemove) { await vscodeApi.removeCustomEndpoint(appId, providerId, url); } // 更新初始端点列表 setInitialCustomUrls(currentCustomUrls); } catch (error) { const message = error instanceof Error ? error.message : t("endpointTest.saveFailed"); setLastError(message); setIsSaving(false); return; } finally { setIsSaving(false); } } // 关闭弹窗 onClose(); }, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]); if (!visible) return null; const footer = (
); return (
{/* 测速控制栏 */}
{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-primary/70 bg-primary/5 shadow-sm" : "border-border-default bg-background hover:bg-muted" }`} >
{/* 选择指示器 */}
{/* 内容 */}
{entry.url}
{/* 右侧信息 */}
{latency !== null ? (
{latency}ms
{entry.status ? t("endpointTest.status", { code: entry.status }) : t("endpointTest.notTested")}
) : isTesting ? ( ) : entry.error ? (
{t("endpointTest.failed")}
) : (
)}
); })}
) : (
{t("endpointTest.empty")}
)} {/* 错误提示 */} {lastError && (
{lastError}
)}
); }; export default EndpointSpeedTest;