diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index ac0ef77..b7ecc85 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -47,84 +47,93 @@ export function AddProviderDialog({ websiteUrl: values.websiteUrl?.trim() || undefined, settingsConfig: parsedConfig, ...(values.presetCategory ? { category: values.presetCategory } : {}), + ...(values.meta ? { meta: values.meta } : {}), }; - // 收集端点候选(仅新增供应商时) - // 1. 从预设配置中获取 endpointCandidates - // 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url) - const urlSet = new Set(); + const hasCustomEndpoints = + providerData.meta?.custom_endpoints && + Object.keys(providerData.meta.custom_endpoints).length > 0; - const addUrl = (rawUrl?: string) => { - const url = (rawUrl || "").trim().replace(/\/+$/, ""); - if (url && url.startsWith("http")) { - urlSet.add(url); - } - }; + if (!hasCustomEndpoints) { + // 收集端点候选(仅在缺少自定义端点时兜底) + // 1. 从预设配置中获取 endpointCandidates + // 2. 从当前配置中提取 baseUrl (ANTHROPIC_BASE_URL 或 Codex base_url) + const urlSet = new Set(); - // 如果选择了预设,获取预设中的 endpointCandidates - if (values.presetId) { - if (appType === "claude") { - const presets = providerPresets; - const presetIndex = parseInt(values.presetId.replace("claude-", "")); - if ( - !isNaN(presetIndex) && - presetIndex >= 0 && - presetIndex < presets.length - ) { - const preset = presets[presetIndex]; - if (preset?.endpointCandidates) { - preset.endpointCandidates.forEach(addUrl); + const addUrl = (rawUrl?: string) => { + const url = (rawUrl || "").trim().replace(/\/+$/, ""); + if (url && url.startsWith("http")) { + urlSet.add(url); + } + }; + + if (values.presetId) { + if (appType === "claude") { + const presets = providerPresets; + const presetIndex = parseInt( + values.presetId.replace("claude-", ""), + ); + if ( + !isNaN(presetIndex) && + presetIndex >= 0 && + presetIndex < presets.length + ) { + const preset = presets[presetIndex]; + if (preset?.endpointCandidates) { + preset.endpointCandidates.forEach(addUrl); + } } + } else if (appType === "codex") { + const presets = codexProviderPresets; + const presetIndex = parseInt( + values.presetId.replace("codex-", ""), + ); + if ( + !isNaN(presetIndex) && + presetIndex >= 0 && + presetIndex < presets.length + ) { + const preset = presets[presetIndex]; + if (Array.isArray(preset.endpointCandidates)) { + preset.endpointCandidates.forEach(addUrl); + } + } + } + } + + if (appType === "claude") { + const env = parsedConfig.env as Record | undefined; + if (env?.ANTHROPIC_BASE_URL) { + addUrl(env.ANTHROPIC_BASE_URL); } } else if (appType === "codex") { - const presets = codexProviderPresets; - const presetIndex = parseInt(values.presetId.replace("codex-", "")); - if ( - !isNaN(presetIndex) && - presetIndex >= 0 && - presetIndex < presets.length - ) { - const preset = presets[presetIndex]; - if ((preset as any).endpointCandidates) { - (preset as any).endpointCandidates.forEach(addUrl); + const config = parsedConfig.config as string | undefined; + if (config) { + const baseUrlMatch = + config.match(/base_url\s*=\s*["']([^"']+)["']/); + if (baseUrlMatch?.[1]) { + addUrl(baseUrlMatch[1]); } } } - } - // 从当前配置中提取 baseUrl - if (appType === "claude") { - const env = parsedConfig.env as Record | undefined; - if (env?.ANTHROPIC_BASE_URL) { - addUrl(env.ANTHROPIC_BASE_URL); - } - } else if (appType === "codex") { - // Codex 的 baseUrl 在 config.toml 字符串中 - const config = parsedConfig.config as string | undefined; - if (config) { - const baseUrlMatch = config.match(/base_url\s*=\s*["']([^"']+)["']/); - if (baseUrlMatch?.[1]) { - addUrl(baseUrlMatch[1]); - } - } - } + const urls = Array.from(urlSet); + if (urls.length > 0) { + const now = Date.now(); + const customEndpoints: Record = {}; + urls.forEach((url) => { + customEndpoints[url] = { + url, + addedAt: now, + lastUsed: undefined, + }; + }); - // 如果收集到了端点,添加到 meta.custom_endpoints - const urls = Array.from(urlSet); - if (urls.length > 0) { - const now = Date.now(); - const customEndpoints: Record = {}; - urls.forEach((url) => { - customEndpoints[url] = { - url, - addedAt: now, - lastUsed: undefined, + providerData.meta = { + ...(providerData.meta ?? {}), + custom_endpoints: customEndpoints, }; - }); - - providerData.meta = { - custom_endpoints: customEndpoints, - }; + } } await onSubmit(providerData); diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 47d4e25..8665aca 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button"; import { Form } from "@/components/ui/form"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { AppType } from "@/lib/api"; -import type { ProviderCategory, CustomEndpoint, ProviderMeta } from "@/types"; +import type { ProviderCategory, ProviderMeta } from "@/types"; import { providerPresets, type ProviderPreset } from "@/config/providerPresets"; import { codexProviderPresets, type CodexProviderPreset, } from "@/config/codexProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; +import { mergeProviderMeta } from "@/utils/providerMetaUtils"; import CodexConfigEditor from "./CodexConfigEditor"; import { CommonConfigEditor } from "./CommonConfigEditor"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; @@ -324,12 +325,9 @@ export function ProviderForm({ } // 处理 meta 字段(新建与编辑使用不同策略) - if (initialData?.meta) { - // 编辑模式:后端已通过 API 更新 meta,直接使用原有值 - payload.meta = initialData.meta; - } else if (customEndpointsMap) { - // 新建模式:从表单收集的自定义端点打包到 meta - payload.meta = { custom_endpoints: customEndpointsMap }; + const mergedMeta = mergeProviderMeta(initialData?.meta, customEndpointsMap); + if (mergedMeta) { + payload.meta = mergedMeta; } onSubmit(payload); @@ -580,7 +578,5 @@ export function ProviderForm({ export type ProviderFormValues = ProviderFormData & { presetId?: string; presetCategory?: ProviderCategory; - meta?: { - custom_endpoints?: Record; - }; + meta?: ProviderMeta; }; diff --git a/src/utils/providerMetaUtils.ts b/src/utils/providerMetaUtils.ts new file mode 100644 index 0000000..be344a0 --- /dev/null +++ b/src/utils/providerMetaUtils.ts @@ -0,0 +1,36 @@ +import type { CustomEndpoint, ProviderMeta } from "@/types"; + +/** + * 合并供应商元数据中的自定义端点。 + * - 当 customEndpoints 为空对象或 null 时,移除自定义端点但保留其它元数据。 + * - 当 customEndpoints 存在时,覆盖原有自定义端点。 + * - 若结果为空对象则返回 undefined,避免写入空 meta。 + */ +export function mergeProviderMeta( + initialMeta: ProviderMeta | undefined, + customEndpoints: + | Record + | null + | undefined, +): ProviderMeta | undefined { + const hasCustomEndpoints = + !!customEndpoints && Object.keys(customEndpoints).length > 0; + + if (hasCustomEndpoints) { + return { + ...(initialMeta ? { ...initialMeta } : {}), + custom_endpoints: customEndpoints!, + }; + } + + if (!initialMeta) { + return undefined; + } + + if ("custom_endpoints" in initialMeta) { + const { custom_endpoints, ...rest } = initialMeta; + return Object.keys(rest).length > 0 ? rest : undefined; + } + + return { ...initialMeta }; +} diff --git a/tests/components/AddProviderDialog.test.tsx b/tests/components/AddProviderDialog.test.tsx new file mode 100644 index 0000000..82d5b1e --- /dev/null +++ b/tests/components/AddProviderDialog.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; +import type { ProviderFormValues } from "@/components/providers/forms/ProviderForm"; + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +let mockFormValues: ProviderFormValues; + +vi.mock("@/components/providers/forms/ProviderForm", () => ({ + ProviderForm: ({ onSubmit }: { onSubmit: (values: ProviderFormValues) => void }) => ( +
{ + event.preventDefault(); + onSubmit(mockFormValues); + }} + /> + ), +})); + +describe("AddProviderDialog", () => { + beforeEach(() => { + mockFormValues = { + name: "Test Provider", + websiteUrl: "https://provider.example.com", + settingsConfig: JSON.stringify({ env: {}, config: {} }), + meta: { + custom_endpoints: { + "https://api.new-endpoint.com": { + url: "https://api.new-endpoint.com", + addedAt: 1, + }, + }, + }, + }; + }); + + it("使用 ProviderForm 返回的自定义端点", async () => { + const handleSubmit = vi.fn().mockResolvedValue(undefined); + const handleOpenChange = vi.fn(); + + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: "common.add", + }), + ); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + + const submitted = handleSubmit.mock.calls[0][0]; + expect(submitted.meta?.custom_endpoints).toEqual( + mockFormValues.meta?.custom_endpoints, + ); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + it("在缺少自定义端点时回退到配置中的 baseUrl", async () => { + const handleSubmit = vi.fn().mockResolvedValue(undefined); + + mockFormValues = { + name: "Base URL Provider", + websiteUrl: "", + settingsConfig: JSON.stringify({ + env: { ANTHROPIC_BASE_URL: "https://claude.base" }, + config: {}, + }), + }; + + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { + name: "common.add", + }), + ); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + + const submitted = handleSubmit.mock.calls[0][0]; + expect(submitted.meta?.custom_endpoints).toEqual({ + "https://claude.base": { + url: "https://claude.base", + addedAt: expect.any(Number), + lastUsed: undefined, + }, + }); + }); +}); diff --git a/tests/utils/providerMetaUtils.test.ts b/tests/utils/providerMetaUtils.test.ts new file mode 100644 index 0000000..544c17b --- /dev/null +++ b/tests/utils/providerMetaUtils.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderMeta } from "@/types"; +import { mergeProviderMeta } from "@/utils/providerMetaUtils"; + +const buildEndpoint = (url: string) => ({ + url, + addedAt: 1, +}); + +describe("mergeProviderMeta", () => { + it("returns undefined when no initial meta and no endpoints", () => { + expect(mergeProviderMeta(undefined, null)).toBeUndefined(); + expect(mergeProviderMeta(undefined, undefined)).toBeUndefined(); + }); + + it("creates meta when endpoints are provided for new provider", () => { + const result = mergeProviderMeta(undefined, { + "https://example.com": buildEndpoint("https://example.com"), + }); + + expect(result).toEqual({ + custom_endpoints: { + "https://example.com": buildEndpoint("https://example.com"), + }, + }); + }); + + it("overrides custom endpoints but preserves other fields", () => { + const initial: ProviderMeta = { + usage_script: { + enabled: true, + language: "javascript", + code: "console.log(1);", + }, + custom_endpoints: { + "https://old.com": buildEndpoint("https://old.com"), + }, + }; + + const result = mergeProviderMeta(initial, { + "https://new.com": buildEndpoint("https://new.com"), + }); + + expect(result).toEqual({ + usage_script: initial.usage_script, + custom_endpoints: { + "https://new.com": buildEndpoint("https://new.com"), + }, + }); + }); + + it("removes custom endpoints when result is empty but keeps other meta", () => { + const initial: ProviderMeta = { + usage_script: { + enabled: true, + language: "javascript", + code: "console.log(1);", + }, + custom_endpoints: { + "https://example.com": buildEndpoint("https://example.com"), + }, + }; + + const result = mergeProviderMeta(initial, null); + + expect(result).toEqual({ + usage_script: initial.usage_script, + }); + }); + + it("returns undefined when removing last field", () => { + const initial: ProviderMeta = { + custom_endpoints: { + "https://example.com": buildEndpoint("https://example.com"), + }, + }; + + expect(mergeProviderMeta(initial, null)).toBeUndefined(); + }); +});