diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index be6ae1ff1..9a3297bda 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -57,6 +57,7 @@ import { useToast } from "@/composables/useToast"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { buildAiContext, resolveAiDatabaseTarget, resolveAiNamespaceSelection, resolveDefaultAiSchema, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai"; import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates"; +import { addConfiguredAiModel, aiModelOptions } from "@/lib/ai/aiConfigList"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; import { effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference"; import { useAiModelCatalog } from "@/composables/useAiModelCatalog"; @@ -345,7 +346,9 @@ const activeFullConfig = computed(() => { }); function getModelsForConfig(configId: string) { - return modelCatalogs.get(configId)?.models ?? []; + const config = settings.aiConfigs.find((item) => item.id === configId); + if (!config) return []; + return aiModelOptions(config, modelCatalogs.get(configId)?.models ?? []); } function configMatchesModelQuery(config: AiConfigItem, query: string): boolean { @@ -420,12 +423,21 @@ function startManualModel(configId: string) { nextTick(() => document.querySelector("[data-manual-model-input]")?.focus()); } -function applyManualModel(configId: string) { +async function applyManualModel(configId: string) { const modelId = manualModelId.value.trim(); if (!modelId) return; - handleModelSelect(configId, modelId); - manualModelConfigId.value = ""; - manualModelId.value = ""; + const config = settings.aiConfigs.find((item) => item.id === configId); + if (!config) return; + try { + if (config.model.trim() !== modelId) { + await settings.updateAiConfigItem(configId, { models: addConfiguredAiModel(config.models, modelId) }); + } + handleModelSelect(configId, modelId); + manualModelConfigId.value = ""; + manualModelId.value = ""; + } catch (error) { + toast(translateBackendError(t, error)); + } } const activeEffortEntry = computed(() => { diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigList.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigList.spec.ts index 569564245..eb3bbd14c 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigList.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigList.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { generateId } from "@/lib/ai/aiConfigList"; +import { addConfiguredAiModel, aiModelOptions, generateId } from "@/lib/ai/aiConfigList"; describe("generateId", () => { afterEach(() => { @@ -22,3 +22,43 @@ describe("generateId", () => { expect(generateId()).toBe("00000000-0000-4000-8000-000000000000"); }); }); + +describe("AI model options", () => { + it("keeps saved models selectable when provider model discovery fails", () => { + const options = aiModelOptions( + { + model: "ark-code-latest", + models: [{ name: "doubao-seed-2.0-code", label: "Doubao Code" }], + }, + [], + ); + + expect(options).toEqual([ + { id: "ark-code-latest", displayName: undefined, supportedEffortLevels: undefined, effortCapability: undefined }, + { id: "doubao-seed-2.0-code", displayName: "Doubao Code", supportedEffortLevels: undefined, effortCapability: undefined }, + ]); + }); + + it("persists a manual model only once", () => { + expect(addConfiguredAiModel([{ name: "ark-code-latest" }], " doubao-seed-2.0-code ")).toEqual([{ name: "ark-code-latest" }, { name: "doubao-seed-2.0-code" }]); + expect(addConfiguredAiModel([{ name: "ark-code-latest" }], "ark-code-latest")).toEqual([{ name: "ark-code-latest" }]); + }); + + it("merges saved metadata with discovered capabilities", () => { + const options = aiModelOptions( + { + model: "", + models: [{ name: "gpt-4o", label: "GPT-4o", supportedEffortLevels: ["high"] }], + }, + [{ id: "gpt-4o", displayName: "Discovered GPT-4o", effortCapability: { kind: "unsupported" } }], + ); + + expect(options).toEqual([{ id: "gpt-4o", displayName: "GPT-4o", supportedEffortLevels: ["high"], effortCapability: { kind: "unsupported" } }]); + }); + + it("handles an empty configured model and blank manual ids", () => { + expect(aiModelOptions({ model: "", models: [{ name: "a-model" }] }, [])).toEqual([{ id: "a-model", displayName: undefined, supportedEffortLevels: undefined, effortCapability: undefined }]); + expect(addConfiguredAiModel(undefined, "new-model")).toEqual([{ name: "new-model" }]); + expect(addConfiguredAiModel([{ name: "existing" }], " ")).toEqual([{ name: "existing" }]); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiConfigList.ts b/apps/desktop/src/lib/ai/aiConfigList.ts index c026284de..144c62465 100644 --- a/apps/desktop/src/lib/ai/aiConfigList.ts +++ b/apps/desktop/src/lib/ai/aiConfigList.ts @@ -1,4 +1,5 @@ -import type { AiConfig, AiConfigItem } from "@/types/ai"; +import type { AiConfig, AiConfigItem, AiConfiguredModel } from "@/types/ai"; +import type { AiModelInfo } from "@/lib/backend/tauri"; import { uuid } from "@/lib/common/utils"; export type { AiConfigItem }; @@ -20,6 +21,46 @@ export function aiConfigToItem(config: AiConfig, id: string, name: string): AiCo }; } +/** + * Combines models saved by the user with models returned by a provider's + * discovery endpoint. Some OpenAI-compatible providers intentionally do not + * expose `/models`, so their saved models must remain selectable on their own. + */ +export function aiModelOptions(config: Pick, discovered: AiModelInfo[]): AiModelInfo[] { + const saved: AiModelInfo[] = [ + config.model.trim() ? { id: config.model.trim() } : null, + ...(config.models ?? []).map((model) => ({ + id: model.name.trim(), + displayName: model.label?.trim() || undefined, + supportedEffortLevels: model.supportedEffortLevels, + })), + ].filter((model): model is AiModelInfo => Boolean(model?.id)); + + const options = new Map(); + for (const model of [...saved, ...discovered]) { + const id = model.id.trim(); + if (!id) continue; + const existing = options.get(id); + options.set(id, { + ...model, + id, + displayName: existing?.displayName ?? model.displayName, + supportedEffortLevels: existing?.supportedEffortLevels ?? model.supportedEffortLevels, + effortCapability: model.effortCapability ?? existing?.effortCapability, + }); + } + return [...options.values()]; +} + +/** Add a manually entered model once while retaining its optional display metadata. */ +export function addConfiguredAiModel(models: AiConfiguredModel[] | undefined, modelId: string): AiConfiguredModel[] { + const id = modelId.trim(); + if (!id) return models ?? []; + const existing = models ?? []; + if (existing.some((model) => model.name.trim() === id)) return existing; + return [...existing, { name: id }]; +} + export type ConfigNameValidationResult = "empty" | "duplicate" | "valid"; export function validateConfigName(name: string, configs: AiConfigItem[], excludeId?: string): ConfigNameValidationResult {