diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index f01b3d6..e02c36b 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -8,6 +8,10 @@ import { Zap } from "lucide-react"; import type { ProviderCategory } from "@/types"; import type { TemplateValueConfig } from "@/config/providerPresets"; +interface EndpointCandidate { + url: string; +} + interface ClaudeFormFieldsProps { // API Key shouldShowApiKey: boolean; @@ -48,6 +52,9 @@ interface ClaudeFormFieldsProps { field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL", value: string ) => void; + + // Speed Test Endpoints + speedTestEndpoints: EndpointCandidate[]; } export function ClaudeFormFields({ @@ -75,6 +82,7 @@ export function ClaudeFormFields({ kimiAnthropicModel, kimiAnthropicSmallFastModel, onKimiModelChange, + speedTestEndpoints, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -195,7 +203,7 @@ export function ClaudeFormFields({ appType="claude" value={baseUrl} onChange={onBaseUrlChange} - initialEndpoints={[{ url: baseUrl }]} + initialEndpoints={speedTestEndpoints} visible={isEndpointModalOpen} onClose={() => onEndpointModalToggle(false)} onCustomEndpointsChange={onCustomEndpointsChange} diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 01f9cf1..6672779 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -6,6 +6,10 @@ import EndpointSpeedTest from "@/components/ProviderForm/EndpointSpeedTest"; import { Zap } from "lucide-react"; import type { ProviderCategory } from "@/types"; +interface EndpointCandidate { + url: string; +} + interface CodexFormFieldsProps { // API Key codexApiKey: string; @@ -21,6 +25,9 @@ interface CodexFormFieldsProps { isEndpointModalOpen: boolean; onEndpointModalToggle: (open: boolean) => void; onCustomEndpointsChange: (endpoints: string[]) => void; + + // Speed Test Endpoints + speedTestEndpoints: EndpointCandidate[]; } export function CodexFormFields({ @@ -35,6 +42,7 @@ export function CodexFormFields({ isEndpointModalOpen, onEndpointModalToggle, onCustomEndpointsChange, + speedTestEndpoints, }: CodexFormFieldsProps) { const { t } = useTranslation(); @@ -120,7 +128,7 @@ export function CodexFormFields({ appType="codex" value={codexBaseUrl} onChange={onBaseUrlChange} - initialEndpoints={[{ url: codexBaseUrl }]} + initialEndpoints={speedTestEndpoints} visible={isEndpointModalOpen} onClose={() => onEndpointModalToggle(false)} onCustomEndpointsChange={onCustomEndpointsChange} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 245b8c6..86dcba4 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -31,6 +31,7 @@ import { useTemplateValues, useCommonConfigSnippet, useCodexCommonConfig, + useSpeedTestEndpoints, } from "./hooks"; const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2); @@ -370,6 +371,16 @@ export function ProviderForm({ codexBaseUrl, }); + // 使用端点测速候选 hook + const speedTestEndpoints = useSpeedTestEndpoints({ + appType, + selectedPresetId, + presetEntries, + baseUrl, + codexBaseUrl, + initialData, + }); + const handlePresetChange = (value: string) => { setSelectedPresetId(value); if (value === "custom") { @@ -470,6 +481,7 @@ export function ProviderForm({ kimiAnthropicModel={kimiAnthropicModel} kimiAnthropicSmallFastModel={kimiAnthropicSmallFastModel} onKimiModelChange={handleKimiModelChange} + speedTestEndpoints={speedTestEndpoints} /> )} @@ -487,6 +499,7 @@ export function ProviderForm({ isEndpointModalOpen={isCodexEndpointModalOpen} onEndpointModalToggle={setIsCodexEndpointModalOpen} onCustomEndpointsChange={setDraftCustomEndpoints} + speedTestEndpoints={speedTestEndpoints} /> )} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index a1d4fb1..09ea653 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -9,3 +9,4 @@ export { useKimiModelSelector } from "./useKimiModelSelector"; export { useTemplateValues } from "./useTemplateValues"; export { useCommonConfigSnippet } from "./useCommonConfigSnippet"; export { useCodexCommonConfig } from "./useCodexCommonConfig"; +export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints"; diff --git a/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts new file mode 100644 index 0000000..4e3b75b --- /dev/null +++ b/src/components/providers/forms/hooks/useSpeedTestEndpoints.ts @@ -0,0 +1,143 @@ +import { useMemo } from "react"; +import type { AppType } from "@/lib/api"; +import type { ProviderPreset } from "@/config/providerPresets"; +import type { CodexProviderPreset } from "@/config/codexProviderPresets"; + +type PresetEntry = { + id: string; + preset: ProviderPreset | CodexProviderPreset; +}; + +interface UseSpeedTestEndpointsProps { + appType: AppType; + selectedPresetId: string | null; + presetEntries: PresetEntry[]; + baseUrl: string; + codexBaseUrl: string; + initialData?: { + settingsConfig?: Record; + }; +} + +export interface EndpointCandidate { + url: string; +} + +/** + * 收集端点测速弹窗的初始端点列表 + * + * 收集来源: + * 1. 当前选中的 Base URL + * 2. 编辑模式下的初始数据 URL + * 3. 预设中的 endpointCandidates + */ +export function useSpeedTestEndpoints({ + appType, + selectedPresetId, + presetEntries, + baseUrl, + codexBaseUrl, + initialData, +}: UseSpeedTestEndpointsProps) { + const claudeEndpoints = useMemo(() => { + if (appType !== "claude") return []; + + const map = new Map(); + const add = (url?: string) => { + if (!url) return; + const sanitized = url.trim().replace(/\/+$/, ""); + if (!sanitized || map.has(sanitized)) return; + map.set(sanitized, { url: sanitized }); + }; + + // 1. 当前 Base URL + if (baseUrl) { + add(baseUrl); + } + + // 2. 编辑模式:初始数据中的 URL + if (initialData && typeof initialData.settingsConfig === "object") { + const envUrl = (initialData.settingsConfig as any)?.env + ?.ANTHROPIC_BASE_URL; + if (typeof envUrl === "string") { + add(envUrl); + } + } + + // 3. 预设中的 endpointCandidates + if (selectedPresetId && selectedPresetId !== "custom") { + const entry = presetEntries.find((item) => item.id === selectedPresetId); + if (entry) { + const preset = entry.preset as ProviderPreset; + // 添加预设自己的 baseUrl + const presetEnv = (preset.settingsConfig as any)?.env + ?.ANTHROPIC_BASE_URL; + if (typeof presetEnv === "string") { + add(presetEnv); + } + // 添加预设的候选端点 + if (Array.isArray((preset as any).endpointCandidates)) { + for (const u of (preset as any).endpointCandidates as string[]) { + add(u); + } + } + } + } + + return Array.from(map.values()); + }, [appType, baseUrl, initialData, selectedPresetId, presetEntries]); + + const codexEndpoints = useMemo(() => { + if (appType !== "codex") return []; + + const map = new Map(); + const add = (url?: string) => { + if (!url) return; + const sanitized = url.trim().replace(/\/+$/, ""); + if (!sanitized || map.has(sanitized)) return; + map.set(sanitized, { url: sanitized }); + }; + + // 1. 当前 Codex Base URL + if (codexBaseUrl) { + add(codexBaseUrl); + } + + // 2. 编辑模式:初始数据中的 URL + const initialCodexConfig = + initialData && typeof initialData.settingsConfig?.config === "string" + ? (initialData.settingsConfig as any).config + : ""; + // 从 TOML 中提取 base_url + const match = /base_url\s*=\s*["']([^"']+)["']/i.exec(initialCodexConfig); + if (match?.[1]) { + add(match[1]); + } + + // 3. 预设中的 endpointCandidates + if (selectedPresetId && selectedPresetId !== "custom") { + const entry = presetEntries.find((item) => item.id === selectedPresetId); + if (entry) { + const preset = entry.preset as CodexProviderPreset; + // 添加预设自己的 baseUrl + const presetConfig = preset.config || ""; + const presetMatch = /base_url\s*=\s*["']([^"']+)["']/i.exec( + presetConfig + ); + if (presetMatch?.[1]) { + add(presetMatch[1]); + } + // 添加预设的候选端点 + if (Array.isArray((preset as any).endpointCandidates)) { + for (const u of (preset as any).endpointCandidates as string[]) { + add(u); + } + } + } + } + + return Array.from(map.values()); + }, [appType, codexBaseUrl, initialData, selectedPresetId, presetEntries]); + + return appType === "codex" ? codexEndpoints : claudeEndpoints; +}