From e6224ec0b0938769d7f5c8f56d263a3976ae587e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 22 Jul 2026 23:09:54 +0800 Subject: [PATCH] fix(ai): refresh model options after adding config --- .../src/components/editor/AiAssistant.vue | 17 ++---- .../__tests__/ai/aiConfigCandidates.spec.ts | 54 +++++++++++++++++++ apps/desktop/src/lib/ai/aiConfigCandidates.ts | 17 ++++++ 3 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts create mode 100644 apps/desktop/src/lib/ai/aiConfigCandidates.ts diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 57c8edb19c..bd5df778d8 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -55,6 +55,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai"; +import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort"; import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/types/promptTemplate"; @@ -297,14 +298,7 @@ const modelSearchQuery = ref(""); // Configured providers for quick switching - get from aiConfigs const configuredProviders = computed(() => { - const providers = orderAiConfigsForDisplay( - settings.aiConfigs.filter((c) => { - // Check directly if config has required fields - const preset = AI_PROVIDER_PRESETS[c.provider]; - if (c.provider === "codex-cli" || c.provider === "claude-code-cli") return true; - return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim()); - }), - ); + const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey))); // Apply search filter - hide providers with no matching models if (modelSearchQuery.value.trim()) { const query = modelSearchQuery.value.trim().toLowerCase(); @@ -334,12 +328,7 @@ const activeFullConfig = computed(() => { function getModelsForConfig(configId: string): string[] { const config = settings.aiConfigs.find((c) => c.id === configId); if (!config) return []; - const models = config.models?.map((m) => m.name) || []; - // Always include the current model - if (config.model && !models.includes(config.model)) { - return [config.model, ...models]; - } - return models; + return getAiConfigModelIds(config); } function getConfigModelOptionIds(configId: string): string[] { diff --git a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts new file mode 100644 index 0000000000..242e706771 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { getAiConfigModelIds, isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates"; +import type { AiConfig } from "@/types/ai"; + +function config(overrides: Partial = {}): AiConfig { + return { + provider: "openai", + apiKey: "secret", + authMethod: "bearer", + endpoint: "https://api.example.com/v1", + model: "gpt-default", + apiStyle: "completions", + ...overrides, + }; +} + +describe("isAiConfigModelCandidate", () => { + it("accepts discovered models when the default model is empty", () => { + expect(isAiConfigModelCandidate(config({ model: "", models: [{ name: "gpt-new" }] }), true)).toBe(true); + }); + + it("keeps endpoint and required API key validation", () => { + expect(isAiConfigModelCandidate(config({ endpoint: "" }), true)).toBe(false); + expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false); + }); + + it.each(["codex-cli", "claude-code-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { + expect( + isAiConfigModelCandidate( + config({ + provider, + endpoint: "", + apiKey: "", + model: "", + models: [], + }), + false, + ), + ).toBe(true); + }); +}); + +describe("getAiConfigModelIds", () => { + it("includes the default model once while preserving configured model order", () => { + expect( + getAiConfigModelIds( + config({ + model: "gpt-default", + models: [{ name: "gpt-fast" }, { name: "gpt-default" }, { name: "gpt-default" }], + }), + ), + ).toEqual(["gpt-fast", "gpt-default"]); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiConfigCandidates.ts b/apps/desktop/src/lib/ai/aiConfigCandidates.ts new file mode 100644 index 0000000000..d86faf4764 --- /dev/null +++ b/apps/desktop/src/lib/ai/aiConfigCandidates.ts @@ -0,0 +1,17 @@ +import type { AiConfig } from "@/types/ai"; + +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli"]); + +export function getAiConfigModelIds(config: Pick): string[] { + const configuredModels = [...new Set(config.models?.map((model) => model.name) ?? [])]; + return config.model && !configuredModels.includes(config.model) ? [config.model, ...configuredModels] : configuredModels; +} + +export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean { + // CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass. + if (CLI_PROVIDERS.has(config.provider)) return true; + if (!config.endpoint?.trim() || (requiresApiKey && !config.apiKey?.trim())) return false; + + // A discovered model list is sufficient even before the user chooses a default model. + return getAiConfigModelIds(config).some((model) => !!model.trim()); +}