diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index db67f512f..842aff529 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -420,6 +420,7 @@ export function ClaudeDesktopProviderForm({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: baseUrl, }); const applyDesktopPreset = (preset: ClaudeDesktopProviderPreset) => { diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 585ee493c..5f7eba2f7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -2050,6 +2050,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: baseUrl, }); const { @@ -2063,6 +2064,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: codexBaseUrl, }); const { @@ -2076,6 +2078,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: geminiBaseUrl, }); const { @@ -2089,6 +2092,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: opencodeForm.opencodeBaseUrl, }); // 使用 API Key 链接 hook (OpenClaw) @@ -2103,6 +2107,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: openclawForm.openclawBaseUrl, }); // 使用 API Key 链接 hook (Hermes) @@ -2117,6 +2122,7 @@ function ProviderFormFull({ selectedPresetId, presetEntries, formWebsiteUrl: form.watch("websiteUrl") || "", + formBaseUrl: hermesForm.hermesBaseUrl, }); // 使用端点测速候选 hook diff --git a/src/components/providers/forms/hooks/useApiKeyLink.ts b/src/components/providers/forms/hooks/useApiKeyLink.ts index 806aecf08..9d046937b 100644 --- a/src/components/providers/forms/hooks/useApiKeyLink.ts +++ b/src/components/providers/forms/hooks/useApiKeyLink.ts @@ -6,6 +6,8 @@ import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets"; import type { ClaudeDesktopProviderPreset } from "@/config/claudeDesktopProviderPresets"; +import { extractBaseUrlForUsageDetection } from "@/config/codingPlanProviders"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; type PresetEntry = { id: string; @@ -23,6 +25,48 @@ interface UseApiKeyLinkProps { selectedPresetId: string | null; presetEntries: PresetEntry[]; formWebsiteUrl: string; + /** 表单当前的请求地址;没选预设(编辑已有供应商)时按它反查预设 */ + formBaseUrl?: string; +} + +const LINK_CATEGORIES: ReadonlyArray = [ + "cn_official", + "aggregator", + "third_party", +]; + +/** 链接指向:这三类优先用 apiKeyUrl(可能带推广参数),其余用官网 */ +function presetLinkUrl(preset: PresetEntry["preset"]): string { + if (LINK_CATEGORIES.includes(preset.category)) { + return preset.apiKeyUrl || preset.websiteUrl || ""; + } + return preset.websiteUrl || ""; +} + +function normalizeBaseUrl(url: string | null | undefined): string { + return (url ?? "").trim().toLowerCase().replace(/\/+$/, ""); +} + +/** 各应用预设的请求地址放在不同位置 */ +function presetBaseUrl(appId: AppId, preset: PresetEntry["preset"]): string { + const p = preset as { + baseUrl?: unknown; + baseURL?: unknown; + config?: unknown; + settingsConfig?: Record; + }; + if (appId === "claude-desktop") { + return typeof p.baseUrl === "string" ? p.baseUrl : ""; + } + if (appId === "codex") { + return typeof p.config === "string" + ? (extractCodexBaseUrl(p.config) ?? "") + : ""; + } + if (appId === "gemini" && typeof p.baseURL === "string") { + return p.baseURL; + } + return extractBaseUrlForUsageDetection(appId, p.settingsConfig) ?? ""; } /** @@ -34,6 +78,7 @@ export function useApiKeyLink({ selectedPresetId, presetEntries, formWebsiteUrl, + formBaseUrl, }: UseApiKeyLinkProps) { // 判断是否显示 API Key 获取链接 const shouldShowApiKeyLink = useMemo(() => { @@ -53,31 +98,38 @@ export function useApiKeyLink({ return undefined; }, [selectedPresetId, presetEntries]); + // 没选预设(编辑已有供应商):供应商不记来源预设,按请求地址反查。 + // 同一地址的几个预设链接或推广语不一致(如同一家的不同版本)时不猜,退回官网链接。 + const matchedPreset = useMemo(() => { + if (currentPresetEntry) return undefined; + const target = normalizeBaseUrl(formBaseUrl); + if (!target) return undefined; + const matches = presetEntries + .map((entry) => entry.preset) + .filter( + (preset) => normalizeBaseUrl(presetBaseUrl(appId, preset)) === target, + ); + const [first] = matches; + if (!first || !presetLinkUrl(first)) return undefined; + const consistent = matches.every( + (preset) => + presetLinkUrl(preset) === presetLinkUrl(first) && + preset.partnerPromotionKey === first.partnerPromotionKey, + ); + return consistent ? first : undefined; + }, [appId, currentPresetEntry, formBaseUrl, presetEntries]); + + const linkPreset = currentPresetEntry?.preset ?? matchedPreset; + // 获取当前供应商的网址(用于 API Key 链接) const getWebsiteUrl = useMemo(() => { - if (currentPresetEntry) { - const preset = currentPresetEntry.preset; - // 对于 cn_official、aggregator、third_party,优先使用 apiKeyUrl(可能包含推广参数) - if ( - preset.category === "cn_official" || - preset.category === "aggregator" || - preset.category === "third_party" - ) { - return preset.apiKeyUrl || preset.websiteUrl || ""; - } - return preset.websiteUrl || ""; - } + if (linkPreset) return presetLinkUrl(linkPreset); return formWebsiteUrl || ""; - }, [currentPresetEntry, formWebsiteUrl]); + }, [linkPreset, formWebsiteUrl]); // 提取合作伙伴信息 - const isPartner = useMemo(() => { - return currentPresetEntry?.preset.isPartner ?? false; - }, [currentPresetEntry]); - - const partnerPromotionKey = useMemo(() => { - return currentPresetEntry?.preset.partnerPromotionKey; - }, [currentPresetEntry]); + const isPartner = linkPreset?.isPartner ?? false; + const partnerPromotionKey = linkPreset?.partnerPromotionKey; return { shouldShowApiKeyLink: diff --git a/tests/hooks/useApiKeyLink.test.tsx b/tests/hooks/useApiKeyLink.test.tsx new file mode 100644 index 000000000..c2d0cf90d --- /dev/null +++ b/tests/hooks/useApiKeyLink.test.tsx @@ -0,0 +1,157 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useApiKeyLink } from "@/components/providers/forms/hooks/useApiKeyLink"; + +type Props = Parameters[0]; +type Entries = Props["presetEntries"]; + +const claudePreset = ( + id: string, + baseUrl: string, + apiKeyUrl: string, + partnerPromotionKey?: string, +) => + ({ + id, + preset: { + name: id, + websiteUrl: `https://${id}.example`, + apiKeyUrl, + category: "aggregator", + isPartner: Boolean(partnerPromotionKey), + partnerPromotionKey, + settingsConfig: { env: { ANTHROPIC_BASE_URL: baseUrl } }, + }, + }) as unknown as Entries[number]; + +const render = (props: Partial) => + renderHook(() => + useApiKeyLink({ + appId: "claude", + category: "aggregator", + selectedPresetId: null, + presetEntries: [], + formWebsiteUrl: "", + ...props, + }), + ).result.current; + +describe("useApiKeyLink", () => { + const entries = [ + claudePreset( + "hub", + "https://hub.example.com", + "https://hub.example/key?ref=cc", + "hub", + ), + claudePreset( + "other", + "https://other.example.com/api", + "https://other.example/key", + ), + ]; + + it("edit mode finds the preset by base URL and links its apiKeyUrl", () => { + const link = render({ + presetEntries: entries, + formBaseUrl: " HTTPS://hub.example.com/ ", + formWebsiteUrl: "https://my-note.example", + }); + expect(link.shouldShowApiKeyLink).toBe(true); + expect(link.websiteUrl).toBe("https://hub.example/key?ref=cc"); + // 推广语跟着链接一起带出来,和添加页一致 + expect(link.isPartner).toBe(true); + expect(link.partnerPromotionKey).toBe("hub"); + }); + + it("falls back to the website field when no preset matches", () => { + const link = render({ + presetEntries: entries, + formBaseUrl: "https://unknown.example.com", + formWebsiteUrl: "https://my-note.example", + }); + expect(link.websiteUrl).toBe("https://my-note.example"); + }); + + it("does not guess when presets sharing the address link to different pages", () => { + const link = render({ + presetEntries: [ + ...entries, + claudePreset( + "hub-plan", + "https://hub.example.com", + "https://hub.example/plan", + ), + ], + formBaseUrl: "https://hub.example.com", + formWebsiteUrl: "https://my-note.example", + }); + expect(link.websiteUrl).toBe("https://my-note.example"); + }); + + it("does not guess when presets sharing the link differ in promotion", () => { + const link = render({ + presetEntries: [ + ...entries, + claudePreset( + "hub-2", + "https://hub.example.com", + "https://hub.example/key?ref=cc", + ), + ], + formBaseUrl: "https://hub.example.com", + formWebsiteUrl: "https://my-note.example", + }); + expect(link.websiteUrl).toBe("https://my-note.example"); + expect(link.partnerPromotionKey).toBeUndefined(); + }); + + it("a selected preset still wins over the base URL lookup", () => { + const link = render({ + presetEntries: entries, + selectedPresetId: "other", + formBaseUrl: "https://hub.example.com", + }); + expect(link.websiteUrl).toBe("https://other.example/key"); + }); + + it("reads each app's preset address from its own field", () => { + const codex = render({ + appId: "codex", + presetEntries: [ + { + id: "codex-0", + preset: { + name: "Hub", + websiteUrl: "https://hub.example", + apiKeyUrl: "https://hub.example/codex-key", + category: "aggregator", + auth: {}, + config: + 'model_provider = "custom"\n\n[model_providers.custom]\nbase_url = "https://hub.example.com/v1"\n', + }, + } as unknown as Entries[number], + ], + formBaseUrl: "https://hub.example.com/v1", + }); + expect(codex.websiteUrl).toBe("https://hub.example/codex-key"); + + const desktop = render({ + appId: "claude-desktop", + presetEntries: [ + { + id: "claude-desktop-0", + preset: { + name: "Hub", + websiteUrl: "https://hub.example", + apiKeyUrl: "https://hub.example/desktop-key", + category: "aggregator", + baseUrl: "https://hub.example.com", + }, + } as unknown as Entries[number], + ], + formBaseUrl: "https://hub.example.com", + }); + expect(desktop.websiteUrl).toBe("https://hub.example/desktop-key"); + }); +});