From c417e546988870ee8d10c8bca3ff1daf73239bbe Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Fri, 17 Jul 2026 18:14:49 +0800 Subject: [PATCH] fix(ai): preserve last-used model during app session --- .../src/components/editor/AiAssistant.vue | 4 - .../stores/__tests__/settingsStore.spec.ts | 135 +++++++++++++++++- apps/desktop/src/stores/settingsStore.ts | 17 ++- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 7c04c9182..397f5e07f 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -1614,10 +1614,6 @@ async function deleteConversation(id: string) { function startNewChat() { clearMessages(); showConversationList.value = false; - const defaultConfig = settings.aiConfigs.find((c) => c.isDefault) || settings.aiConfigs[0]; - if (defaultConfig) { - settings.updateActiveModel({ configId: defaultConfig.id, modelId: defaultConfig.model }); - } } onMounted(async () => { diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index a31257841..d2f9567a8 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeDesktopSettings, normalizeEditorSettings } from "@/stores/settingsStore"; +import { createPinia, setActivePinia } from "pinia"; +import type { AiConfigItem } from "@/types/ai"; describe("normalizeEditorSettings", () => { it("enables automatic table aliases by default", () => { @@ -162,3 +164,134 @@ describe("normalizeEditorSettings - tabLayout", () => { expect(normalizeEditorSettings({ tabLayout: 123 } as any).tabLayout).toBe("scroll"); }); }); + +// --- Helpers for Pinia store tests --- + +function makeTestConfig(overrides: Partial & { id: string }): AiConfigItem { + return { + provider: "openai", + apiKey: "", + authMethod: "api-key", + endpoint: "https://api.openai.com/v1/chat/completions", + model: "gpt-4o-mini", + apiStyle: "completions", + name: overrides.id, + ...overrides, + } as AiConfigItem; +} + +// --- activeModel lifecycle tests --- + +describe("settingsStore activeModel lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + setActivePinia(createPinia()); + }); + + it("updateActiveModel persists the model and does not change any config isDefault", async () => { + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue([]), + loadAiConfig: vi.fn().mockResolvedValue(null), + loadAiProviderConfigs: vi.fn().mockResolvedValue(null), + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + + store.aiConfigs = [makeTestConfig({ id: "c1", model: "model-a", isDefault: true }), makeTestConfig({ id: "c2", model: "model-b", isDefault: false })]; + store.isAiConfigLoaded = true; + + store.updateActiveModel({ configId: "c1", modelId: "model-a" }); + expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" }); + + store.updateActiveModel({ configId: "c2", modelId: "model-b" }); + expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" }); + + // 核心保障:不改变任何配置的 isDefault + expect(store.aiConfigs[0].isDefault).toBe(true); + expect(store.aiConfigs[1].isDefault).toBe(false); + }); + + it("setDefaultAiConfig(id) on success points activeModel to the new default config", async () => { + const setDefaultAiConfig = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue([]), + loadAiConfig: vi.fn().mockResolvedValue(null), + loadAiProviderConfigs: vi.fn().mockResolvedValue(null), + setDefaultAiConfig, + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + + store.aiConfigs = [makeTestConfig({ id: "c1", model: "model-a", isDefault: true }), makeTestConfig({ id: "c2", model: "model-b", isDefault: false })]; + store.isAiConfigLoaded = true; + + // 先手动切到非默认的配置 + store.updateActiveModel({ configId: "c2", modelId: "model-b" }); + expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" }); + + await store.setDefaultAiConfig("c2"); + + expect(setDefaultAiConfig).toHaveBeenCalledWith("c2"); + expect(store.aiConfigs[0].isDefault).toBe(false); + expect(store.aiConfigs[1].isDefault).toBe(true); + expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" }); + }); + + it("setDefaultAiConfig does not mutate state when backend call fails", async () => { + const error = new Error("backend error"); + const setDefaultAiConfig = vi.fn().mockRejectedValue(error); + + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue([]), + loadAiConfig: vi.fn().mockResolvedValue(null), + loadAiProviderConfigs: vi.fn().mockResolvedValue(null), + setDefaultAiConfig, + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + + store.aiConfigs = [makeTestConfig({ id: "c1", model: "model-a", isDefault: true }), makeTestConfig({ id: "c2", model: "model-b", isDefault: false })]; + store.isAiConfigLoaded = true; + store.updateActiveModel({ configId: "c1", modelId: "model-a" }); + + await expect(store.setDefaultAiConfig("c2")).rejects.toThrow("backend error"); + + // isDefault 不变 + expect(store.aiConfigs[0].isDefault).toBe(true); + expect(store.aiConfigs[1].isDefault).toBe(false); + // activeModel 不变 + expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" }); + }); + + it("reloadAiConfigs sets activeModel to null when config list is empty", async () => { + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue([]), + loadAiConfig: vi.fn().mockResolvedValue(null), + loadAiProviderConfigs: vi.fn().mockResolvedValue(null), + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + store.isAiConfigLoaded = false; + await store.reloadAiConfigs(); + expect(store.activeModel).toBeNull(); + }); + + it("reloadAiConfigs points activeModel to isDefault config, not first in list", async () => { + const configs = [makeTestConfig({ id: "c1", model: "model-a", isDefault: false }), makeTestConfig({ id: "c2", model: "model-b", isDefault: true }), makeTestConfig({ id: "c3", model: "model-c", isDefault: false })]; + + vi.doMock("@/lib/backend/api", () => ({ + loadAiConfigs: vi.fn().mockResolvedValue(configs), + })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + store.isAiConfigLoaded = false; + await store.reloadAiConfigs(); + expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" }); + }); +}); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 75c4dad99..be6abbcb4 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -944,7 +944,8 @@ export const useSettingsStore = defineStore("settings", () => { await migrateToMultiConfig(); } - // 同步活跃状态到默认配置 + // 重置 activeModel 到默认配置是有意行为——activeModel 是本次运行 (run-scoped) 的末次使用选择, + // 应用启动和配置同步下载 (reloadAiConfigs) 两条路径均需丢弃会话内手动切换的模型、回到默认。 const defaultConfig = aiConfigs.value.find((c) => c.isDefault) || aiConfigs.value[0]; if (defaultConfig) { activeModel.value = { configId: defaultConfig.id, modelId: defaultConfig.model }; @@ -956,14 +957,7 @@ export const useSettingsStore = defineStore("settings", () => { async function reloadAiConfigs(): Promise { isAiConfigLoaded.value = false; await initAiConfigs(); - // If the active config was deleted, fall back to the default - if (activeModel.value && !aiConfigs.value.find((c) => c.id === activeModel.value!.configId)) { - if (aiConfigs.value.length > 0) { - activeModel.value = { configId: aiConfigs.value[0].id, modelId: aiConfigs.value[0].model }; - } else { - activeModel.value = null; - } - } + if (aiConfigs.value.length === 0) activeModel.value = null; } async function migrateToMultiConfig(): Promise { @@ -1029,6 +1023,11 @@ export const useSettingsStore = defineStore("settings", () => { aiConfigs.value.forEach((c) => { c.isDefault = c.id === id; }); + const config = aiConfigs.value.find((c) => c.id === id); + if (config) { + // 修改默认配置时丢弃用户手动选择的模型,回到新默认——放在 await 之后确保后端持久化成功才执行 + activeModel.value = { configId: config.id, modelId: config.model }; + } } function updateActiveModel(model: { configId: string; modelId: string }) {