feat(ai): unify model discovery and effort handling
This commit is contained in:
1 parent
c54b95e274
commit
c7e7b7c9d9
41 files changed
+3291
-634
No files matched your search
+146
-6
@@ -326,7 +326,10 @@ describe("settingsStore AI API key normalization", () => {
|
||||
|
||||
it("trims API keys before persisting new configurations", async () => {
|
||||
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({ saveAiConfigItem }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
saveAiConfigItem,
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
@@ -432,6 +435,8 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
@@ -451,13 +456,15 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
expect(store.aiConfigs[1].isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it("setDefaultAiConfig(id) on success points activeModel to the new default config", async () => {
|
||||
it("setDefaultAiConfig(id) changes the fallback config without replacing the active model", 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),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
setDefaultAiConfig,
|
||||
}));
|
||||
|
||||
@@ -467,16 +474,15 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
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" });
|
||||
store.updateActiveModel({ configId: "c1", modelId: "model-a" });
|
||||
expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" });
|
||||
|
||||
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" });
|
||||
expect(store.activeModel).toEqual({ configId: "c1", modelId: "model-a" });
|
||||
});
|
||||
|
||||
it("setDefaultAiConfig does not mutate state when backend call fails", async () => {
|
||||
@@ -487,6 +493,8 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
setDefaultAiConfig,
|
||||
}));
|
||||
|
||||
@@ -511,6 +519,8 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
@@ -525,6 +535,8 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadAiConfigs: vi.fn().mockResolvedValue(configs),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
@@ -533,4 +545,132 @@ describe("settingsStore activeModel lifecycle", () => {
|
||||
await store.reloadAiConfigs();
|
||||
expect(store.activeModel).toEqual({ configId: "c2", modelId: "model-b" });
|
||||
});
|
||||
|
||||
it("restores the locally persisted model and per-model effort independently of legacy config fields", async () => {
|
||||
const configs = [makeTestConfig({ id: "c1", model: "", isDefault: true })];
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadAiConfigs: vi.fn().mockResolvedValue(configs),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue({
|
||||
version: 1,
|
||||
active: { configId: "c1", modelId: "runtime-model" },
|
||||
effortPreferences: [{ configId: "c1", modelId: "runtime-model", selection: { kind: "enum", value: "high" } }],
|
||||
}),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
await store.initAiConfigs();
|
||||
|
||||
expect(store.activeModel).toEqual({ configId: "c1", modelId: "runtime-model" });
|
||||
expect(store.activeEffort).toEqual({ kind: "enum", value: "high" });
|
||||
});
|
||||
|
||||
it("does not invent an active model when the first saved provider has no legacy model", async () => {
|
||||
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
saveAiConfigItem: vi.fn().mockResolvedValue(undefined),
|
||||
saveAiChatSelection,
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
await store.createAiConfig(makeTestConfig({ id: "c1", model: "", isDefault: true }));
|
||||
|
||||
expect(store.activeModel).toBeNull();
|
||||
expect(saveAiChatSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the active model and effort when an existing config changes provider", async () => {
|
||||
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
|
||||
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
saveAiConfigItem,
|
||||
saveAiChatSelection,
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs = [makeTestConfig({ id: "c1", provider: "openai", model: "" })];
|
||||
store.updateActiveModel({ configId: "c1", modelId: "gpt-5" });
|
||||
store.updateActiveEffort({ kind: "enum", value: "high" });
|
||||
|
||||
await store.updateAiConfigItem("c1", { provider: "gemini" });
|
||||
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenLastCalledWith({ version: 1, active: undefined, effortPreferences: [] }));
|
||||
|
||||
expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", provider: "gemini" }));
|
||||
expect(store.activeModel).toBeNull();
|
||||
expect(store.activeEffort).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the active model and effort when connection details change within the same provider", async () => {
|
||||
const saveAiConfigItem = vi.fn().mockResolvedValue(undefined);
|
||||
const saveAiChatSelection = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
saveAiConfigItem,
|
||||
saveAiChatSelection,
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs = [makeTestConfig({ id: "c1", provider: "openai", model: "" })];
|
||||
store.updateActiveModel({ configId: "c1", modelId: "gpt-5" });
|
||||
store.updateActiveEffort({ kind: "enum", value: "high" });
|
||||
|
||||
await store.updateAiConfigItem("c1", { endpoint: "https://gateway.example/v1" });
|
||||
|
||||
expect(saveAiConfigItem).toHaveBeenCalledWith(expect.objectContaining({ id: "c1", endpoint: "https://gateway.example/v1" }));
|
||||
expect(store.activeModel).toEqual({ configId: "c1", modelId: "gpt-5" });
|
||||
expect(store.activeEffort).toEqual({ kind: "enum", value: "high" });
|
||||
});
|
||||
|
||||
it("serializes rapid model and effort persistence without allowing an older snapshot to win", async () => {
|
||||
let releaseFirstSave!: () => void;
|
||||
const firstSave = new Promise<void>((resolve) => {
|
||||
releaseFirstSave = resolve;
|
||||
});
|
||||
const saveAiChatSelection = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => firstSave)
|
||||
.mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
saveAiChatSelection,
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
store.updateActiveModel({ configId: "c1", modelId: "model-a" });
|
||||
store.updateActiveEffort({ kind: "enum", value: "high" });
|
||||
|
||||
expect(saveAiChatSelection).toHaveBeenCalledTimes(1);
|
||||
releaseFirstSave();
|
||||
await vi.waitFor(() => expect(saveAiChatSelection).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(saveAiChatSelection.mock.calls[1][0]).toEqual({
|
||||
version: 1,
|
||||
active: { configId: "c1", modelId: "model-a" },
|
||||
effortPreferences: [{ configId: "c1", modelId: "model-a", selection: { kind: "enum", value: "high" } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale in-memory AI configs and selections when a reload returns no configs", async () => {
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadAiConfigs: vi.fn().mockResolvedValue([]),
|
||||
loadAiConfig: vi.fn().mockResolvedValue(null),
|
||||
loadAiProviderConfigs: vi.fn().mockResolvedValue(null),
|
||||
loadAiChatSelection: vi.fn().mockResolvedValue(null),
|
||||
saveAiChatSelection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
store.aiConfigs = [makeTestConfig({ id: "stale", model: "stale-model", isDefault: true })];
|
||||
store.activeModel = { configId: "stale", modelId: "stale-model" };
|
||||
store.isAiConfigLoaded = false;
|
||||
|
||||
await store.reloadAiConfigs();
|
||||
|
||||
expect(store.aiConfigs).toEqual([]);
|
||||
expect(store.activeModel).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,9 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
|
||||
import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
|
||||
import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai";
|
||||
import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection, AiModelEffortPreference } from "@/types/ai";
|
||||
|
||||
export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem };
|
||||
export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection };
|
||||
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
@@ -980,6 +980,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const settingsPageActive = ref(false);
|
||||
const settingsNavigationRequest = ref<SettingsNavigationRequest | null>(null);
|
||||
const activeModel = ref<{ configId: string; modelId: string } | null>(null);
|
||||
const effortPreferences = ref<AiModelEffortPreference[]>([]);
|
||||
const isAiConfigLoaded = ref(false);
|
||||
const aiConfigs = ref<AiConfigItem[]>([]);
|
||||
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
|
||||
@@ -987,6 +988,8 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const isDesktopSettingsLoaded = ref(false);
|
||||
const isMcpGlobalPolicyLoaded = ref(false);
|
||||
const isEditorSettingsLoaded = ref(false);
|
||||
let pendingAiChatSelection: AiChatSelectionState | null = null;
|
||||
let aiChatSelectionSaveRunning = false;
|
||||
|
||||
const editorSettings = ref<EditorSettings>(normalizeEditorSettings({}));
|
||||
|
||||
@@ -1094,14 +1097,21 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
aiConfigs.value = newConfigs.map(normalizeAiConfigItem);
|
||||
} else {
|
||||
// 迁移旧格式
|
||||
aiConfigs.value = [];
|
||||
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 };
|
||||
const savedSelection = await api.loadAiChatSelection().catch(() => null);
|
||||
effortPreferences.value = (savedSelection?.effortPreferences ?? []).filter((preference) => aiConfigs.value.some((config) => config.id === preference.configId));
|
||||
|
||||
const savedActive = savedSelection?.active;
|
||||
const savedConfig = savedActive ? aiConfigs.value.find((config) => config.id === savedActive.configId) : undefined;
|
||||
if (savedConfig && savedActive?.modelId.trim()) {
|
||||
activeModel.value = { configId: savedConfig.id, modelId: savedActive.modelId.trim() };
|
||||
} else {
|
||||
const fallback = aiConfigs.value.find((config) => config.isDefault) || aiConfigs.value[0];
|
||||
activeModel.value = fallback?.model.trim() ? { configId: fallback.id, modelId: fallback.model.trim() } : null;
|
||||
if (activeModel.value) persistAiChatSelection();
|
||||
}
|
||||
|
||||
isAiConfigLoaded.value = true;
|
||||
@@ -1110,7 +1120,6 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
async function reloadAiConfigs(): Promise<void> {
|
||||
isAiConfigLoaded.value = false;
|
||||
await initAiConfigs();
|
||||
if (aiConfigs.value.length === 0) activeModel.value = null;
|
||||
}
|
||||
|
||||
async function migrateToMultiConfig(): Promise<void> {
|
||||
@@ -1153,23 +1162,36 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
const normalized = normalizeAiConfigItem(config);
|
||||
await api.saveAiConfigItem(normalized);
|
||||
aiConfigs.value.push(normalized);
|
||||
if (aiConfigs.value.length === 1) {
|
||||
if (aiConfigs.value.length === 1 && normalized.model.trim()) {
|
||||
activeModel.value = { configId: normalized.id, modelId: normalized.model };
|
||||
persistAiChatSelection();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAiConfigItem(id: string, config: Partial<AiConfigItem>): Promise<void> {
|
||||
const index = aiConfigs.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
const updated = normalizeAiConfigItem({ ...aiConfigs.value[index], ...config });
|
||||
const previous = aiConfigs.value[index];
|
||||
const updated = normalizeAiConfigItem({ ...previous, ...config });
|
||||
await api.saveAiConfigItem(updated);
|
||||
aiConfigs.value[index] = updated;
|
||||
if (previous.provider !== updated.provider) {
|
||||
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== id);
|
||||
if (activeModel.value?.configId === id) activeModel.value = null;
|
||||
persistAiChatSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAiConfig(id: string): Promise<void> {
|
||||
await api.deleteAiConfig(id);
|
||||
aiConfigs.value = aiConfigs.value.filter((c) => c.id !== id);
|
||||
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== id);
|
||||
if (activeModel.value?.configId === id) {
|
||||
const fallback = aiConfigs.value.find((config) => config.isDefault) || aiConfigs.value[0];
|
||||
activeModel.value = fallback?.model.trim() ? { configId: fallback.id, modelId: fallback.model.trim() } : null;
|
||||
}
|
||||
persistAiChatSelection();
|
||||
}
|
||||
|
||||
async function setDefaultAiConfig(id: string): Promise<void> {
|
||||
@@ -1177,15 +1199,57 @@ 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 }) {
|
||||
activeModel.value = model;
|
||||
activeModel.value = { configId: model.configId, modelId: model.modelId.trim() };
|
||||
persistAiChatSelection();
|
||||
}
|
||||
|
||||
const activeEffort = computed<AiEffortSelection | null>(() => {
|
||||
const active = activeModel.value;
|
||||
if (!active) return null;
|
||||
return effortPreferences.value.find((preference) => preference.configId === active.configId && preference.modelId === active.modelId)?.selection ?? null;
|
||||
});
|
||||
|
||||
function updateActiveEffort(selection: AiEffortSelection | null) {
|
||||
const active = activeModel.value;
|
||||
if (!active) return;
|
||||
effortPreferences.value = effortPreferences.value.filter((preference) => preference.configId !== active.configId || preference.modelId !== active.modelId);
|
||||
if (selection) {
|
||||
effortPreferences.value.push({
|
||||
configId: active.configId,
|
||||
modelId: active.modelId,
|
||||
selection,
|
||||
});
|
||||
}
|
||||
persistAiChatSelection();
|
||||
}
|
||||
|
||||
function persistAiChatSelection() {
|
||||
pendingAiChatSelection = {
|
||||
version: 1,
|
||||
active: activeModel.value ? { ...activeModel.value } : undefined,
|
||||
effortPreferences: effortPreferences.value.map((preference) => ({
|
||||
...preference,
|
||||
selection: { ...preference.selection },
|
||||
})),
|
||||
};
|
||||
if (!aiChatSelectionSaveRunning) void flushAiChatSelection();
|
||||
}
|
||||
|
||||
async function flushAiChatSelection() {
|
||||
aiChatSelectionSaveRunning = true;
|
||||
try {
|
||||
while (pendingAiChatSelection) {
|
||||
const selection = pendingAiChatSelection;
|
||||
pendingAiChatSelection = null;
|
||||
await api.saveAiChatSelection(selection).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
aiChatSelectionSaveRunning = false;
|
||||
if (pendingAiChatSelection) void flushAiChatSelection();
|
||||
}
|
||||
}
|
||||
|
||||
const isConfigured = computed((): boolean => {
|
||||
@@ -1352,6 +1416,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
requestSettingsNavigation,
|
||||
clearSettingsNavigationRequest,
|
||||
activeModel,
|
||||
activeEffort,
|
||||
isAiConfigLoaded,
|
||||
aiConfigs,
|
||||
initAiConfigs,
|
||||
@@ -1362,6 +1427,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
deleteAiConfig,
|
||||
setDefaultAiConfig,
|
||||
updateActiveModel,
|
||||
updateActiveEffort,
|
||||
isConfigured,
|
||||
isEditorSettingsLoaded,
|
||||
editorSettings,
|
||||
|
||||
Reference in new issue
Block a user