diff --git a/src/lib/codexModelCatalogOrder.ts b/src/lib/codexModelCatalogOrder.ts new file mode 100644 index 00000000..6f77998b --- /dev/null +++ b/src/lib/codexModelCatalogOrder.ts @@ -0,0 +1,123 @@ +/** + * MultiRouter 聚合模型目录的排序 SSOT。 + * + * 聚合目录会因为删除供应商、路由增删、`/models` 刷新而整体重建。如果顺序完全由 + * “route 迭代顺序 × 目标 provider 目录顺序”推导,任何集合变化都会让老模型跳位。 + * 这里统一按上一次目录的相对顺序补位,真正新增的模型追加到末尾。 + */ + +export interface CodexOrderableCatalogModel { + model?: string; + sortIndex?: number; +} + +/** + * 读取上一次目录的有效顺序:`sortIndex` 优先,缺失时退回数组下标。 + * + * 返回“模型 -> 名次”映射;重复模型只记录第一次出现的名次。 + */ +function buildPreviousRankByModel( + previousModels: readonly CodexOrderableCatalogModel[], +): Map { + const ranked = previousModels + .map((model, index) => ({ + id: model.model?.trim() ?? "", + index, + sortIndex: model.sortIndex, + })) + .filter((entry) => Boolean(entry.id)) + .sort( + (left, right) => + (left.sortIndex ?? Number.MAX_SAFE_INTEGER) - + (right.sortIndex ?? Number.MAX_SAFE_INTEGER) || + left.index - right.index, + ); + + const rankByModel = new Map(); + for (const entry of ranked) { + if (!rankByModel.has(entry.id)) rankByModel.set(entry.id, rankByModel.size); + } + return rankByModel; +} + +/** 判断目录是否启用过自定义排序(存在任意 `sortIndex`)。 */ +export function hasCustomCatalogOrder( + models: readonly CodexOrderableCatalogModel[], +): boolean { + return models.some((model) => model.sortIndex !== undefined); +} + +/** + * 按当前数组顺序落地 `sortIndex`。 + * + * 启用自定义排序时写入稠密序号(0 起),删除中间模型不留空洞、新增模型拿到末尾序号; + * 未启用时清掉可能从上游 provider 继承来的 `sortIndex`,让数组顺序继续表达默认顺序。 + */ +function writeCatalogSortIndexes( + models: readonly T[], + useCustomOrder: boolean, +): T[] { + if (useCustomOrder) { + return models.map((model, index) => ({ ...model, sortIndex: index })); + } + return models.map((model) => { + if (model.sortIndex === undefined) return model; + const { sortIndex: _sortIndex, ...rest } = model; + return rest as T; + }); +} + +/** + * 按上一次目录顺序重排重建结果,并把新增模型追加到末尾。 + * + * 上一次目录使用过自定义排序时(存在任意 `sortIndex`),返回值会为全部模型写入 + * 稠密 `sortIndex`(0 起),这样删除中间模型不会留下空洞,新增模型也拿到末尾序号, + * 而不是回落到后端的默认供应商启发式排序。上一次目录没有自定义排序时保持“无 + * `sortIndex`”语义,只由数组顺序表达默认顺序,让“恢复默认”继续生效。 + * + * 上游 provider 目录里的 `sortIndex` 不参与聚合排序:聚合目录的顺序偏好只属于 + * MultiRouter 自身,否则模型源刷新会把无关序号带进方案。 + */ +export function applyCodexCatalogModelOrder< + T extends CodexOrderableCatalogModel, +>( + nextModels: readonly T[], + previousModels: readonly CodexOrderableCatalogModel[], +): T[] { + const rankByModel = buildPreviousRankByModel(previousModels); + const ordered = nextModels + .map((model, index) => ({ + model, + index, + rank: rankByModel.get(model.model?.trim() ?? ""), + })) + .sort( + (left, right) => + (left.rank ?? Number.MAX_SAFE_INTEGER) - + (right.rank ?? Number.MAX_SAFE_INTEGER) || left.index - right.index, + ) + .map((entry) => entry.model); + + return writeCatalogSortIndexes( + ordered, + hasCustomCatalogOrder(previousModels), + ); +} + +/** + * 用户在向导里显式给出顺序时,数组顺序即最终顺序,只需要落地 `sortIndex`。 + * + * 上一次目录用过自定义排序就继续写稠密序号,否则清掉继承来的序号,避免后端 + * 因残留 `sortIndex` 与数组顺序冲突而回落到默认供应商启发式排序。 + */ +export function applyCodexCatalogExplicitOrder< + T extends CodexOrderableCatalogModel, +>( + nextModels: readonly T[], + previousModels: readonly CodexOrderableCatalogModel[], +): T[] { + return writeCatalogSortIndexes( + nextModels, + hasCustomCatalogOrder(previousModels), + ); +} diff --git a/src/lib/codexMultiRouterSync.ts b/src/lib/codexMultiRouterSync.ts index 2d0bf148..4d92f4ed 100644 --- a/src/lib/codexMultiRouterSync.ts +++ b/src/lib/codexMultiRouterSync.ts @@ -12,6 +12,7 @@ import { resolveWizardModelNameCollisions, } from "@/lib/codexMultiRouterWizard"; import { readCodexModelCatalog } from "@/utils/codexSpawnAgentCandidates"; +import { applyCodexCatalogModelOrder } from "@/lib/codexModelCatalogOrder"; // MultiRouter 同步返回写回后的 plan,以及需要用户人工补选的子 Agent 候选删减。 export interface CodexMultiRouterPlanSyncResult { @@ -305,6 +306,7 @@ function rebuildPlanModelCatalog( } { const byModel = new Map(); const planCatalogByModel = buildPlanCatalogByModel(plan); + const previousModels = readCodexModelCatalog(plan).models; for (const route of routes) { if (route.enabled === false) continue; const targetId = routeTargetProviderId(route); @@ -337,10 +339,10 @@ function rebuildPlanModelCatalog( // 路由同步会重建模型元数据,但全量菜单排序属于 MultiRouter 自身的用户偏好, // 不能因模型源刷新或规则保存而被覆盖。 - const models = Array.from(byModel.entries()).map(([id, model]) => { - const sortIndex = planCatalogByModel.get(id)?.sortIndex; - return sortIndex === undefined ? model : { ...model, sortIndex }; - }); + const models = applyCodexCatalogModelOrder( + Array.from(byModel.values()), + previousModels, + ); const existingSpawnAgentModels = Array.isArray( plan.settingsConfig?.modelCatalog?.spawnAgentModels, ) @@ -533,3 +535,53 @@ export function syncCodexMultiRouterPlansAfterProviderChange( Boolean(result), ); } + +// provider 删除后同步所有引用它的 MultiRouter:route 一并移除,聚合目录只删除 +// 该供应商贡献的模型,剩余模型沿用上一次目录的相对顺序补位。 +export function syncCodexMultiRouterPlansAfterProviderDelete( + providers: Provider[], + deletedProviderId: string, +): CodexMultiRouterPlanSyncResult[] { + const providersById = new Map( + providers + .filter((provider) => provider.id !== deletedProviderId) + .map((provider) => [provider.id, provider]), + ); + + return providers + .filter((provider) => provider.id !== deletedProviderId) + .filter(isCodexMultiRouterPlan) + .map((plan) => { + const routing = plan.settingsConfig?.codexRouting as + | CodexRoutingConfig + | undefined; + const routes = routing?.routes ?? []; + if ( + !routes.some( + (route) => routeTargetProviderId(route) === deletedProviderId, + ) + ) { + return null; + } + + const planWithoutDeletedRoute: Provider = { + ...plan, + settingsConfig: { + ...plan.settingsConfig, + codexRouting: { + ...routing, + routes: routes.filter( + (route) => routeTargetProviderId(route) !== deletedProviderId, + ), + }, + }, + }; + return syncCodexMultiRouterPlanWithProviders( + planWithoutDeletedRoute, + providersById, + ); + }) + .filter((result): result is CodexMultiRouterPlanSyncResult => + Boolean(result), + ); +} diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 05bd201c..aaf27ae5 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -13,6 +13,7 @@ import { proxyKeys } from "@/lib/query/proxy"; import { usageKeys } from "@/lib/query/usage"; import { syncCodexMultiRouterPlansAfterProviderChange, + syncCodexMultiRouterPlansAfterProviderDelete, type CodexMultiRouterPlanSyncResult, } from "@/lib/codexMultiRouterSync"; @@ -289,6 +290,16 @@ export const useDeleteProviderMutation = (appId: AppId) => { return useMutation({ mutationFn: async (providerId: string) => { await providersApi.delete(providerId, appId); + if (appId === "codex") { + const providerMap = await providersApi.getAll(appId); + const syncResults = syncCodexMultiRouterPlansAfterProviderDelete( + Object.values(providerMap), + providerId, + ); + for (const result of syncResults) { + await providersApi.update(result.plan, appId); + } + } }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["providers", appId] }); diff --git a/tests/hooks/useDeleteProviderMutation.test.tsx b/tests/hooks/useDeleteProviderMutation.test.tsx new file mode 100644 index 00000000..ce05ab08 --- /dev/null +++ b/tests/hooks/useDeleteProviderMutation.test.tsx @@ -0,0 +1,170 @@ +import type { ReactNode } from "react"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDeleteProviderMutation } from "@/lib/query/mutations"; +import type { Provider } from "@/types"; + +const apiMocks = vi.hoisted(() => ({ + delete: vi.fn(), + update: vi.fn(), + getAll: vi.fn(), + updateTrayMenu: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + providersApi: { + delete: (...args: unknown[]) => apiMocks.delete(...args), + update: (...args: unknown[]) => apiMocks.update(...args), + getAll: (...args: unknown[]) => apiMocks.getAll(...args), + updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args), + }, + sessionsApi: {}, + settingsApi: {}, +})); + +vi.mock("@/hooks/useHermes", () => ({ + invalidateHermesProviderCaches: vi.fn(), +})); + +vi.mock("@/hooks/useOpenClaw", () => ({ + openclawKeys: { + health: ["openclaw", "health"], + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? key, + }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { wrapper }; +} + +function provider(overrides: Partial): Provider { + return { + id: overrides.id ?? "provider", + name: overrides.name ?? "Provider", + settingsConfig: overrides.settingsConfig ?? {}, + ...overrides, + }; +} + +beforeEach(() => { + apiMocks.delete.mockReset().mockResolvedValue(true); + apiMocks.update.mockReset().mockResolvedValue(true); + apiMocks.getAll.mockReset().mockResolvedValue({}); + apiMocks.updateTrayMenu.mockReset().mockResolvedValue(undefined); +}); + +describe("useDeleteProviderMutation", () => { + it("删除 Codex 供应商后同步 MultiRouter,只移除关联模型并保留原有顺序", async () => { + const alpha = provider({ + id: "alpha", + settingsConfig: { + modelCatalog: { models: [{ model: "a" }, { model: "b" }] }, + }, + }); + const plan = provider({ + id: "router", + name: "Codex MultiRouter", + settingsConfig: { + modelCatalog: { + models: [ + { model: "c", sortIndex: 0 }, + { model: "a", sortIndex: 1 }, + { model: "b", sortIndex: 2 }, + ], + spawnAgentModels: ["c", "a"], + }, + codexRouting: { + enabled: true, + routes: [ + { + id: "router-alpha", + targetProviderId: "alpha", + match: { models: ["a", "b"] }, + upstream: { + apiFormat: "openai_chat", + auth: { source: "provider_config" }, + }, + }, + { + id: "router-beta", + targetProviderId: "beta", + match: { models: ["c"] }, + upstream: { + apiFormat: "openai_chat", + auth: { source: "provider_config" }, + }, + }, + ], + }, + }, + }); + apiMocks.getAll.mockResolvedValue({ alpha, router: plan }); + + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useDeleteProviderMutation("codex"), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync("beta"); + }); + + expect(apiMocks.delete).toHaveBeenCalledWith("beta", "codex"); + expect(apiMocks.update).toHaveBeenCalledTimes(1); + const [syncedPlan] = apiMocks.update.mock.calls[0] as [Provider]; + expect( + syncedPlan.settingsConfig.codexRouting.routes.map( + (route: { id: string }) => route.id, + ), + ).toEqual(["router-alpha"]); + expect( + syncedPlan.settingsConfig.modelCatalog.models.map( + (model: { model: string; sortIndex?: number }) => [ + model.model, + model.sortIndex, + ], + ), + ).toEqual([ + ["a", 0], + ["b", 1], + ]); + }); + + it("非 Codex 应用删除供应商不触发 MultiRouter 同步", async () => { + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useDeleteProviderMutation("claude"), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync("provider-1"); + }); + + expect(apiMocks.delete).toHaveBeenCalledWith("provider-1", "claude"); + expect(apiMocks.getAll).not.toHaveBeenCalled(); + expect(apiMocks.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/codexModelCatalogOrder.test.ts b/tests/lib/codexModelCatalogOrder.test.ts new file mode 100644 index 00000000..a2dc80e5 --- /dev/null +++ b/tests/lib/codexModelCatalogOrder.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { applyCodexCatalogModelOrder } from "@/lib/codexModelCatalogOrder"; + +describe("applyCodexCatalogModelOrder", () => { + it("保留模型按上一次自定义顺序补位,并压缩 sortIndex 空洞", () => { + const previous = [ + { model: "e", sortIndex: 0 }, + { model: "c", sortIndex: 1 }, + { model: "a", sortIndex: 2 }, + { model: "d", sortIndex: 3 }, + { model: "b", sortIndex: 4 }, + ]; + // 删除某个供应商后重建结果只剩它以外的模型,且顺序来自 route 迭代。 + const rebuilt = [{ model: "a" }, { model: "b" }, { model: "e" }]; + + expect(applyCodexCatalogModelOrder(rebuilt, previous)).toEqual([ + { model: "e", sortIndex: 0 }, + { model: "a", sortIndex: 1 }, + { model: "b", sortIndex: 2 }, + ]); + }); + + it("新增模型统一追加到末尾", () => { + const previous = [ + { model: "b", sortIndex: 0 }, + { model: "a", sortIndex: 1 }, + ]; + const rebuilt = [ + { model: "a" }, + { model: "new-1" }, + { model: "b" }, + { model: "new-2" }, + ]; + + expect(applyCodexCatalogModelOrder(rebuilt, previous)).toEqual([ + { model: "b", sortIndex: 0 }, + { model: "a", sortIndex: 1 }, + { model: "new-1", sortIndex: 2 }, + { model: "new-2", sortIndex: 3 }, + ]); + }); + + it("上一次没有自定义排序时只按数组顺序补位,不写入 sortIndex", () => { + const previous = [{ model: "e" }, { model: "c" }, { model: "a" }]; + const rebuilt = [{ model: "a" }, { model: "new" }, { model: "e" }]; + + expect(applyCodexCatalogModelOrder(rebuilt, previous)).toEqual([ + { model: "e" }, + { model: "a" }, + { model: "new" }, + ]); + }); + + it("上游 provider 带来的 sortIndex 不会污染未自定义排序的聚合目录", () => { + const previous = [{ model: "a" }, { model: "b" }]; + const rebuilt = [ + { model: "b", sortIndex: 7 }, + { model: "a", sortIndex: 3 }, + ]; + + expect(applyCodexCatalogModelOrder(rebuilt, previous)).toEqual([ + { model: "a" }, + { model: "b" }, + ]); + }); +}); diff --git a/tests/lib/codexMultiRouterSync.test.ts b/tests/lib/codexMultiRouterSync.test.ts index 183a5018..35c2c521 100644 --- a/tests/lib/codexMultiRouterSync.test.ts +++ b/tests/lib/codexMultiRouterSync.test.ts @@ -401,9 +401,9 @@ describe("codexMultiRouterSync", () => { ), ).toEqual([ "deepseek-chat", + "qwen3.6", "deepseek-reasoner", "deepseek-v4-flash", - "qwen3.6", ]); expect(synced?.plan.settingsConfig.modelCatalog.spawnAgentModels).toEqual([ "qwen3.6",