From 5cc3a9236f09aad076ecae37cde90493363ee0db Mon Sep 17 00:00:00 2001
From: KagurazakaSakuya <2471012170@qq.com>
Date: Sat, 30 May 2026 19:59:39 +0900
Subject: [PATCH 1/6] feat: add model preferences
---
.prettierignore | 2 +
.../src-tauri/resources/rtk/manifest.json | 1 +
apps/desktop/src/components/AlertMessage.vue | 37 +-
apps/desktop/src/components/appIconMap.ts | 4 +
.../drizzle/0002_model_preferences.sql | 20 +
.../src/database/drizzle/meta/_journal.json | 7 +
apps/desktop/src/database/queries/models.ts | 190 +++++++-
apps/desktop/src/database/schema.ts | 32 ++
apps/desktop/src/database/types/index.ts | 31 ++
apps/desktop/src/i18n/messages.ts | 127 +++++-
.../services/AgentService/prompt/composer.ts | 7 +-
.../AgentService/prompt/modelPreferences.ts | 100 +++++
.../tools/upgradeModel/constants.ts | 30 +-
.../tools/upgradeModel/helper.ts | 10 +-
.../tools/upgradeModel/index.ts | 215 ++++++++-
.../src/services/EventService/types.ts | 1 +
apps/desktop/src/stores/settings.ts | 22 +
.../AiServices/components/ModelCard.vue | 26 +-
.../AiServices/components/ModelGroup.vue | 13 +-
.../AiServices/components/ModelList.vue | 32 +-
.../AiServices/components/ProviderCard.vue | 10 +-
.../AiServices/components/ProviderList.vue | 4 -
.../components/AiServices/index.vue | 90 ++--
.../components/ModelPreferenceDialog.vue | 425 ++++++++++++++++++
.../General/components/ModelPreferences.vue | 386 ++++++++++++++++
.../SettingsView/components/General/index.vue | 58 ++-
.../components/ModelPreferences/index.vue | 15 +
apps/desktop/src/views/SettingsView/index.vue | 22 +
.../views/SettingsView/settingsNavigation.ts | 9 +-
.../SettingsView/ai-services-i18n.test.ts | 12 +-
.../SettingsView/model-card-i18n.test.ts | 14 +-
.../navigation-sidebar-i18n.test.ts | 1 +
.../prompt/model-preferences.test.ts | 118 +++++
.../tools/upgradeModel/i18n.test.ts | 149 +++++-
.../SettingsView/modelGroupBehavior.test.ts | 15 +-
.../SettingsView/modelListEmptyState.test.ts | 2 +-
.../settingsAiServicesLayout.test.ts | 24 +-
.../settingsGeneralComponent.test.ts | 22 +-
.../SettingsView/settingsNavigation.test.ts | 5 +
eslint.config.js | 4 +
40 files changed, 2107 insertions(+), 185 deletions(-)
create mode 100644 apps/desktop/src/database/drizzle/0002_model_preferences.sql
create mode 100644 apps/desktop/src/services/AgentService/prompt/modelPreferences.ts
create mode 100644 apps/desktop/src/views/SettingsView/components/General/components/ModelPreferenceDialog.vue
create mode 100644 apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
create mode 100644 apps/desktop/src/views/SettingsView/components/ModelPreferences/index.vue
create mode 100644 apps/desktop/tests/services/AgentService/prompt/model-preferences.test.ts
diff --git a/.prettierignore b/.prettierignore
index 5b8b2f5b..432a4ef0 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -52,6 +52,8 @@ temp/
.worktrees/
.e2e-runtime/
.e2e-tools/
+.vite-cache/
+**/.vite-cache/
.cargo-temp/
rust-target/
rust-temp/
diff --git a/apps/desktop/src-tauri/resources/rtk/manifest.json b/apps/desktop/src-tauri/resources/rtk/manifest.json
index 4b3a12ff..390c2781 100644
--- a/apps/desktop/src-tauri/resources/rtk/manifest.json
+++ b/apps/desktop/src-tauri/resources/rtk/manifest.json
@@ -5,6 +5,7 @@
"x86_64-pc-windows-msvc": {
"size": 3884282,
"digest": "7fc90190f76f55dc170898d0ac755e89f405fc2d1d89f717ad8600640ab0f1ed",
+ "binary_digest": "3f8786f4bc120a10dd83728d4e481e74460fede13e811c9d8849098a48d0b33e",
"format": "zip",
"archivePath": "rtk.exe",
"url": "https://github.com/rtk-ai/rtk/releases/download/v0.40.0/rtk-x86_64-pc-windows-msvc.zip"
diff --git a/apps/desktop/src/components/AlertMessage.vue b/apps/desktop/src/components/AlertMessage.vue
index 92aa081d..5aa0d2ec 100644
--- a/apps/desktop/src/components/AlertMessage.vue
+++ b/apps/desktop/src/components/AlertMessage.vue
@@ -1,7 +1,26 @@
+
+
+
+
+ {{
+ preference.id === null
+ ? t('settings.general.modelPreferences.addPreferenceTitle')
+ : t('settings.general.modelPreferences.editPreferenceTitle')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+ {{ getProviderFallbackText(option) }}
+
+
+ {{ option.label }}
+
+
+ {{ t('settings.builtInTools.upgradeModel.provider') }}
+
+
+
+
+
+
+
![]()
+
+ {{ getProviderFallbackText(option) }}
+
+
+
+ {{ option.label }}
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+ {{ t('settings.builtInTools.upgradeModel.model') }}
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+ {{ option.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue b/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
new file mode 100644
index 00000000..b9f5fa07
--- /dev/null
+++ b/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+
+
+ {{ t('settings.general.modelPreferences.title') }}
+
+
+ {{ t('settings.general.modelPreferences.description') }}
+
+
+
+
+
+
+
+ {{ t(modelRole.titleKey) }}
+
+
+ {{ t(modelRole.descriptionKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.general.modelPreferences.scenarioPreferencesTitle') }}
+
+
+ {{ t('settings.general.modelPreferences.scenarioPreferencesDescription') }}
+
+
+
+
+
+
+
+ {{ t('common.loading') }}
+
+
+
+ {{ t('settings.general.modelPreferences.empty') }}
+
+
+
+
+
+
+ {{ preference.name }}
+
+
+ {{ preference.description }}
+
+
+ {{
+ formatPreferenceModel(preference) ||
+ t('settings.general.modelPreferences.unavailableModel')
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue
index fe8aaed8..b39c109f 100644
--- a/apps/desktop/src/views/SettingsView/components/General/index.vue
+++ b/apps/desktop/src/views/SettingsView/components/General/index.vue
@@ -361,6 +361,23 @@
}
};
+ const saveAllowModelAutoSwitch = async (previousValue: boolean) => {
+ try {
+ await settingsStore.updateAllowModelAutoSwitch(settings.value.allowModelAutoSwitch);
+ alertMessage.value?.success(t('common.saved'), 2000);
+ } catch (error) {
+ settings.value.allowModelAutoSwitch = previousValue;
+ console.error('Failed to save allow_model_auto_switch setting:', error);
+ alertMessage.value?.error(t('settings.general.saveSettingsFailed'), 3000);
+ }
+ };
+
+ const toggleAllowModelAutoSwitch = () => {
+ const previousValue = settings.value.allowModelAutoSwitch;
+ settings.value.allowModelAutoSwitch = !previousValue;
+ void saveAllowModelAutoSwitch(previousValue);
+ };
+
const saveOutputScrollBehavior = async () => {
try {
await settingsStore.updateOutputScrollBehavior(settings.value.outputScrollBehavior);
@@ -632,7 +649,46 @@
-
+
+
+
+
+ {{ t('settings.general.allowModelAutoSwitch') }}
+
+
+ {{ t('settings.general.allowModelAutoSwitchDescription') }}
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/components/ModelPreferences/index.vue b/apps/desktop/src/views/SettingsView/components/ModelPreferences/index.vue
new file mode 100644
index 00000000..e0e07b75
--- /dev/null
+++ b/apps/desktop/src/views/SettingsView/components/ModelPreferences/index.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/index.vue b/apps/desktop/src/views/SettingsView/index.vue
index 8b89123a..c5786c17 100644
--- a/apps/desktop/src/views/SettingsView/index.vue
+++ b/apps/desktop/src/views/SettingsView/index.vue
@@ -24,6 +24,9 @@
});
const GeneralView = defineAsyncComponent(() => import('./components/General/index.vue'));
+ const ModelPreferencesView = defineAsyncComponent(
+ () => import('./components/ModelPreferences/index.vue')
+ );
const AiServicesView = defineAsyncComponent(() => import('./components/AiServices/index.vue'));
const BuiltInToolsView = defineAsyncComponent(
() => import('./components/BuiltInTools/index.vue')
@@ -40,9 +43,11 @@
const SETTINGS_ENTRY_LOADING_DELAY_MS = 180;
let loadingDelayTimer: ReturnType
| null = null;
const generalScrollRef = ref(null);
+ const modelPreferencesScrollRef = ref(null);
const dataScrollRef = ref(null);
const isWindowMaximized = ref(false);
useScrollbarStabilizer(generalScrollRef);
+ useScrollbarStabilizer(modelPreferencesScrollRef);
useScrollbarStabilizer(dataScrollRef);
const shellVisibilityClass = computed(() =>
initialLoadingVisible.value ? 'opacity-0' : 'opacity-100'
@@ -254,6 +259,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/views/SettingsView/settingsNavigation.ts b/apps/desktop/src/views/SettingsView/settingsNavigation.ts
index 1dad7331..8764c267 100644
--- a/apps/desktop/src/views/SettingsView/settingsNavigation.ts
+++ b/apps/desktop/src/views/SettingsView/settingsNavigation.ts
@@ -4,6 +4,7 @@ import { type MessageKey, t } from '@/i18n';
export type NavigationSection =
| 'general'
+ | 'model-preferences'
| 'ai-services'
| 'built-in-tools'
| 'mcp-tools'
@@ -50,10 +51,16 @@ const settingsNavigationDefinitions: SettingsNavigationGroupDefinition[] = [
items: [
{
id: 'ai-services',
- icon: 'llm',
+ icon: 'cloud',
labelKey: 'settings.nav.aiServices.label',
descriptionKey: 'settings.nav.aiServices.description',
},
+ {
+ id: 'model-preferences',
+ icon: 'llm',
+ labelKey: 'settings.nav.modelPreferences.label',
+ descriptionKey: 'settings.nav.modelPreferences.description',
+ },
{
id: 'built-in-tools',
icon: 'tool',
diff --git a/apps/desktop/tests/SettingsView/ai-services-i18n.test.ts b/apps/desktop/tests/SettingsView/ai-services-i18n.test.ts
index 54041888..dece213d 100644
--- a/apps/desktop/tests/SettingsView/ai-services-i18n.test.ts
+++ b/apps/desktop/tests/SettingsView/ai-services-i18n.test.ts
@@ -182,19 +182,18 @@ describe('AiServices i18n and layout', () => {
setLocale('zh-CN');
});
- it('renders provider list chrome and default badge in English with wrapping-safe classes', () => {
+ it('renders provider list chrome in English with wrapping-safe classes', () => {
setLocale('en-US');
const wrapper = mount(ProviderList, {
props: {
providers: [createProvider({ is_builtin: 1 })],
selectedProviderId: 1,
- defaultModelProviderIds: new Set([1]),
},
});
expect(wrapper.text()).toContain('Add custom provider');
- expect(wrapper.text()).toContain('Default');
+ expect(wrapper.text()).toContain('Built-in');
expect(wrapper.text()).not.toContain('大模型服务');
const addButton = wrapper.get('button');
@@ -209,7 +208,7 @@ describe('AiServices i18n and layout', () => {
props: {
providerId: 1,
models: [createModel()],
- defaultModelId: null,
+ entryModelId: null,
provider: createProvider(),
providerEnabled: true,
refreshing: false,
@@ -234,7 +233,7 @@ describe('AiServices i18n and layout', () => {
props: {
providerId: 1,
models: [],
- defaultModelId: null,
+ entryModelId: null,
provider: createProvider({ api_endpoint: '' }),
providerEnabled: true,
refreshing: false,
@@ -308,7 +307,6 @@ describe('AiServices i18n and layout', () => {
props: {
providers: [createProvider({ name: '服务商', is_builtin: 1 })],
selectedProviderId: 1,
- defaultModelProviderIds: new Set([1]),
},
attachTo: document.body,
});
@@ -316,7 +314,7 @@ describe('AiServices i18n and layout', () => {
const localizer = createDomLocalizer(document.body);
localizer.translateNow();
- expect(wrapper.text()).toContain('Default');
+ expect(wrapper.text()).toContain('Built-in');
expect(wrapper.get('.provider-card h3').text()).toBe('服务商');
expect(wrapper.get('.provider-card h3').attributes('data-no-i18n')).toBe('true');
diff --git a/apps/desktop/tests/SettingsView/model-card-i18n.test.ts b/apps/desktop/tests/SettingsView/model-card-i18n.test.ts
index 306f3173..4fe8c1f1 100644
--- a/apps/desktop/tests/SettingsView/model-card-i18n.test.ts
+++ b/apps/desktop/tests/SettingsView/model-card-i18n.test.ts
@@ -82,7 +82,7 @@ describe('ModelCard i18n', () => {
const wrapper = mount(ModelCard, {
props: {
model: createModel(),
- isDefault: false,
+ isEntryModel: false,
providerEnabled: true,
},
});
@@ -98,15 +98,13 @@ describe('ModelCard i18n', () => {
const wrapper = mount(ModelCard, {
props: {
model: createModel(),
- isDefault: false,
+ isEntryModel: false,
providerEnabled: false,
},
});
- const defaultRadio = wrapper.get('input[type="radio"]');
const buttons = wrapper.findAll('button');
- expect(defaultRadio.attributes('title')).toBe('Enable this provider first');
expect(buttons[0]?.attributes('title')).toBe('Edit');
expect(buttons[1]?.attributes('title')).toBe('Delete');
@@ -121,13 +119,13 @@ describe('ModelCard i18n', () => {
});
});
- it('localizes default-model delete validation in English', async () => {
+ it('localizes entry-model delete validation in English', async () => {
setLocale('en-US');
const wrapper = mount(ModelCard, {
props: {
model: createModel(),
- isDefault: true,
+ isEntryModel: true,
providerEnabled: true,
},
});
@@ -135,7 +133,7 @@ describe('ModelCard i18n', () => {
await wrapper.findAll('button')[1]?.trigger('click');
expect(alertErrorMock).toHaveBeenCalledWith(
- 'Cannot delete the default model. Set another model as default first.'
+ 'Cannot delete the entry model. Select another entry model in General settings first.'
);
});
@@ -145,7 +143,7 @@ describe('ModelCard i18n', () => {
const wrapper = mount(ModelCard, {
props: {
model: createModel({ name: '设置' }),
- isDefault: false,
+ isEntryModel: false,
providerEnabled: true,
},
attachTo: document.body,
diff --git a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts
index 1b61b7c4..d98ec556 100644
--- a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts
+++ b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts
@@ -56,6 +56,7 @@ describe('Settings navigation sidebar i18n', () => {
expect(flattenSettingsNavigation().map((item) => item.label)).toEqual([
'General',
'Providers and models',
+ 'Model settings',
'Built-in tools',
'MCP tools',
'Data management',
diff --git a/apps/desktop/tests/services/AgentService/prompt/model-preferences.test.ts b/apps/desktop/tests/services/AgentService/prompt/model-preferences.test.ts
new file mode 100644
index 00000000..c6c26d65
--- /dev/null
+++ b/apps/desktop/tests/services/AgentService/prompt/model-preferences.test.ts
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { buildModelPreferencesPrompt } from '@/services/AgentService/prompt/modelPreferences';
+
+const queries = vi.hoisted(() => ({
+ findModelRoleWithProvider: vi.fn(),
+ listModelPreferences: vi.fn(),
+}));
+
+vi.mock('@database/queries', () => queries);
+
+function createModel(overrides = {}) {
+ return {
+ id: 1,
+ provider_id: 1,
+ model_id: 'model-a',
+ name: 'Model A',
+ provider_name: 'Provider A',
+ provider_enabled: 1,
+ ...overrides,
+ };
+}
+
+function createPreference(overrides = {}) {
+ return {
+ id: 1,
+ name: '前端开发',
+ description: 'React、Vue、CSS、Tailwind',
+ provider_id: 1,
+ model_id: 10,
+ priority: 0,
+ created_at: '',
+ updated_at: '',
+ model_name: 'Claude Sonnet',
+ model_api_id: 'claude-sonnet',
+ model_provider_id: 1,
+ provider_name: 'Anthropic',
+ provider_enabled: 1,
+ ...overrides,
+ };
+}
+
+describe('model preferences prompt', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ queries.findModelRoleWithProvider.mockImplementation((role: string) => {
+ if (role === 'entry') {
+ return Promise.resolve(createModel({ name: 'Entry Model' }));
+ }
+ if (role === 'fast') {
+ return Promise.resolve(createModel({ name: 'Fast Model' }));
+ }
+ if (role === 'general') {
+ return Promise.resolve(createModel({ name: 'General Model' }));
+ }
+ return Promise.resolve(null);
+ });
+ queries.listModelPreferences.mockResolvedValue([createPreference()]);
+ });
+
+ it('injects model roles and usable scenario preferences', async () => {
+ const [prompt] = await buildModelPreferencesPrompt();
+
+ expect(prompt).toContain('## Model routing preferences');
+ expect(prompt).toContain('Entry model');
+ expect(prompt).toContain('Fast model');
+ expect(prompt).toContain('General model');
+ expect(prompt).toContain('Provider A / Entry Model');
+ expect(prompt).toContain('Provider A / Fast Model');
+ expect(prompt).toContain('Provider A / General Model');
+ expect(prompt).toContain(
+ '| 前端开发 | React、Vue、CSS、Tailwind | Anthropic / Claude Sonnet |'
+ );
+ expect(prompt).toContain('{ "scenario": "" }');
+ });
+
+ it('omits unusable scenario preferences without failing prompt construction', async () => {
+ queries.listModelPreferences.mockResolvedValue([
+ createPreference({ model_id: null }),
+ createPreference({ name: '翻译', provider_enabled: 0 }),
+ ]);
+
+ const [prompt] = await buildModelPreferencesPrompt();
+
+ expect(prompt).toContain(
+ '| None configured | No custom scenario preferences are available. | - |'
+ );
+ expect(prompt).not.toContain('| 翻译 |');
+ });
+
+ it('escapes markdown table control characters in user-configured text', async () => {
+ queries.listModelPreferences.mockResolvedValue([
+ createPreference({
+ name: 'Frontend | UI',
+ description: 'React\\Vue\nCSS | Tailwind',
+ provider_name: 'Provider\\A',
+ model_name: 'Model | A',
+ }),
+ ]);
+
+ const [prompt] = await buildModelPreferencesPrompt();
+
+ expect(prompt).toContain(
+ '| Frontend \\| UI | React\\\\Vue CSS \\| Tailwind | Provider\\\\A / Model \\| A |'
+ );
+ });
+
+ it('falls back to an empty preference prompt when preference queries fail', async () => {
+ queries.listModelPreferences.mockRejectedValue(new Error('database unavailable'));
+
+ const [prompt] = await buildModelPreferencesPrompt();
+
+ expect(prompt).toContain('## Model routing preferences');
+ expect(prompt).toContain(
+ '| None configured | No custom scenario preferences are available. | - |'
+ );
+ });
+});
diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/upgradeModel/i18n.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/upgradeModel/i18n.test.ts
index 7c675875..6d0672ce 100644
--- a/apps/desktop/tests/services/BuiltInToolService/tools/upgradeModel/i18n.test.ts
+++ b/apps/desktop/tests/services/BuiltInToolService/tools/upgradeModel/i18n.test.ts
@@ -12,12 +12,29 @@ import {
} from '@/services/BuiltInToolService/tools/upgradeModel/chain';
import type { BaseBuiltInToolExecutionContext } from '@/services/BuiltInToolService/types';
-const { findModelByProviderAndModelIdMock } = vi.hoisted(() => ({
+const {
+ findDefaultModelWithProviderMock,
+ findEffectiveModelRoleWithProviderMock,
+ findModelByIdWithProviderMock,
+ findModelByProviderAndModelIdMock,
+ findModelPreferenceByNameMock,
+ getSettingValueMock,
+} = vi.hoisted(() => ({
+ findDefaultModelWithProviderMock: vi.fn(),
+ findEffectiveModelRoleWithProviderMock: vi.fn(),
+ findModelByIdWithProviderMock: vi.fn(),
findModelByProviderAndModelIdMock: vi.fn(),
+ findModelPreferenceByNameMock: vi.fn(),
+ getSettingValueMock: vi.fn(),
}));
vi.mock('@database/queries', () => ({
+ findDefaultModelWithProvider: findDefaultModelWithProviderMock,
+ findEffectiveModelRoleWithProvider: findEffectiveModelRoleWithProviderMock,
+ findModelByIdWithProvider: findModelByIdWithProviderMock,
findModelByProviderAndModelId: findModelByProviderAndModelIdMock,
+ findModelPreferenceByName: findModelPreferenceByNameMock,
+ getSettingValue: getSettingValueMock,
}));
function createModel(overrides: Partial): ModelWithProvider {
@@ -65,6 +82,7 @@ describe('UpgradeModel i18n', () => {
beforeEach(() => {
setLocale('zh-CN');
vi.clearAllMocks();
+ getSettingValueMock.mockResolvedValue('false');
});
it('creates an English approval request for the resolved target model', async () => {
@@ -184,4 +202,133 @@ describe('UpgradeModel i18n', () => {
'Each model upgrade chain item must include providerId and a non-empty modelId.'
);
});
+
+ it('switches to a configured scenario model without exposing provider details to the model', async () => {
+ setLocale('en-US');
+ const targetModel = createModel({
+ id: 20,
+ provider_id: 2,
+ model_id: 'claude-sonnet',
+ name: 'Claude Sonnet',
+ provider_name: 'Anthropic',
+ });
+ findModelPreferenceByNameMock.mockResolvedValue({
+ id: 1,
+ name: 'Frontend',
+ description: 'React and CSS work',
+ model_id: 20,
+ });
+ findModelByIdWithProviderMock.mockResolvedValue(targetModel);
+
+ const result = await executeUpgradeModelTool(
+ { scenario: 'Frontend' },
+ { chain: [] },
+ createContext(createModel({ id: 10 }))
+ );
+
+ expect(findModelPreferenceByNameMock).toHaveBeenCalledWith('Frontend');
+ expect(result).toMatchObject({
+ isError: false,
+ status: 'success',
+ controlSignal: {
+ type: 'upgrade_model',
+ targetModel,
+ restartCurrentRequest: false,
+ },
+ });
+ expect(result.result).toContain('Switched model for scenario: Frontend');
+ expect(result.result).toContain('Target model: Anthropic / Claude Sonnet');
+ });
+
+ it('does not request approval or emit a switch signal when the target is the current model', async () => {
+ setLocale('en-US');
+ const currentModel = createModel({
+ id: 20,
+ provider_id: 2,
+ model_id: 'claude-sonnet',
+ name: 'Claude Sonnet',
+ provider_name: 'Anthropic',
+ });
+ findModelPreferenceByNameMock.mockResolvedValue({
+ id: 1,
+ name: 'Frontend',
+ description: 'React and CSS work',
+ model_id: 20,
+ });
+ findModelByIdWithProviderMock.mockResolvedValue(currentModel);
+
+ const approval = await buildUpgradeModelApprovalRequest(
+ { scenario: 'Frontend' },
+ { chain: [] },
+ 'builtin:upgrade_model',
+ createContext(currentModel)
+ );
+ const result = await executeUpgradeModelTool(
+ { scenario: 'Frontend' },
+ { chain: [] },
+ createContext(currentModel)
+ );
+
+ expect(approval).toBeNull();
+ expect(result).toMatchObject({
+ isError: false,
+ status: 'success',
+ });
+ expect(result.controlSignal).toBeUndefined();
+ expect(result.result).toContain('Model is already the target model');
+ });
+
+ it('skips approval when automatic model switching is enabled', async () => {
+ setLocale('en-US');
+ getSettingValueMock.mockResolvedValueOnce('true');
+ const currentModel = createModel({
+ id: 10,
+ provider_id: 1,
+ model_id: 'model-a',
+ name: 'Model A',
+ provider_name: 'Provider A',
+ });
+ const targetModel = createModel({
+ id: 20,
+ provider_id: 2,
+ model_id: 'model-b',
+ name: 'Model B',
+ provider_name: 'Provider B',
+ });
+ findModelByProviderAndModelIdMock
+ .mockResolvedValueOnce(currentModel)
+ .mockResolvedValueOnce(targetModel);
+
+ const approval = await buildUpgradeModelApprovalRequest(
+ {},
+ {
+ chain: [
+ { providerId: 1, modelId: 'model-a' },
+ { providerId: 2, modelId: 'model-b' },
+ ],
+ },
+ 'builtin:upgrade_model',
+ createContext(currentModel)
+ );
+
+ expect(approval).toBeNull();
+ });
+
+ it('rejects conflicting model switch target selectors', async () => {
+ setLocale('en-US');
+
+ const result = await executeUpgradeModelTool(
+ { role: 'fast', scenario: 'Frontend' },
+ { chain: [] },
+ createContext()
+ );
+
+ expect(result).toMatchObject({
+ isError: true,
+ status: 'error',
+ errorMessage: 'Specify only one model switch target at a time',
+ });
+ expect(findEffectiveModelRoleWithProviderMock).not.toHaveBeenCalled();
+ expect(findModelPreferenceByNameMock).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/desktop/tests/views/SettingsView/modelGroupBehavior.test.ts b/apps/desktop/tests/views/SettingsView/modelGroupBehavior.test.ts
index 5e0437e2..81f4cfae 100644
--- a/apps/desktop/tests/views/SettingsView/modelGroupBehavior.test.ts
+++ b/apps/desktop/tests/views/SettingsView/modelGroupBehavior.test.ts
@@ -64,19 +64,18 @@ describe('ModelGroup behavior', () => {
groupName: 'gpt',
models: [model],
},
- defaultModelId: null,
+ entryModelId: null,
providerEnabled: true,
},
global: {
stubs: {
ModelCard: {
props: ['model'],
- emits: ['update', 'delete', 'set-default', 'edit'],
+ emits: ['update', 'delete', 'edit'],
template: `
-
`,
@@ -95,16 +94,14 @@ describe('ModelGroup behavior', () => {
await wrapper.get('[data-testid="model-update"]').trigger('click');
await wrapper.get('[data-testid="model-delete"]').trigger('click');
- await wrapper.get('[data-testid="model-default"]').trigger('click');
await wrapper.get('[data-testid="model-edit"]').trigger('click');
expect(wrapper.emitted('update')?.[0]).toEqual([7, { name: 'next' }]);
expect(wrapper.emitted('delete')?.[0]).toEqual([7]);
- expect(wrapper.emitted('set-default')?.[0]).toEqual([7]);
expect(wrapper.emitted('edit')?.[0]).toEqual([model]);
});
- it('blocks deleting a group that contains the default model', async () => {
+ it('blocks deleting a group that contains the entry model', async () => {
const wrapper = mount(ModelGroup, {
props: {
group: {
@@ -112,7 +109,7 @@ describe('ModelGroup behavior', () => {
groupName: 'gpt',
models: [createModel(1, 'gpt-5')],
},
- defaultModelId: 1,
+ entryModelId: 1,
providerEnabled: true,
},
global: {
@@ -125,7 +122,7 @@ describe('ModelGroup behavior', () => {
await wrapper.find('button[title="删除分组"]').trigger('click');
await flushPromises();
- expect(warningMock).toHaveBeenCalledWith('该分组包含默认模型,无法批量删除');
+ expect(warningMock).toHaveBeenCalledWith('该分组包含入口模型,无法批量删除');
expect(confirmMock).not.toHaveBeenCalled();
expect(wrapper.emitted('delete-group')).toBeUndefined();
});
@@ -139,7 +136,7 @@ describe('ModelGroup behavior', () => {
groupName: 'gpt',
models: [createModel(1, 'gpt-5')],
},
- defaultModelId: null,
+ entryModelId: null,
providerEnabled: true,
},
global: {
diff --git a/apps/desktop/tests/views/SettingsView/modelListEmptyState.test.ts b/apps/desktop/tests/views/SettingsView/modelListEmptyState.test.ts
index 7e52da4c..40f0c2d2 100644
--- a/apps/desktop/tests/views/SettingsView/modelListEmptyState.test.ts
+++ b/apps/desktop/tests/views/SettingsView/modelListEmptyState.test.ts
@@ -26,7 +26,7 @@ describe('ModelList empty state', () => {
props: {
providerId: 1,
models: [],
- defaultModelId: null,
+ entryModelId: null,
providerEnabled: true,
refreshing: false,
provider: {
diff --git a/apps/desktop/tests/views/SettingsView/settingsAiServicesLayout.test.ts b/apps/desktop/tests/views/SettingsView/settingsAiServicesLayout.test.ts
index d76496f5..d038f474 100644
--- a/apps/desktop/tests/views/SettingsView/settingsAiServicesLayout.test.ts
+++ b/apps/desktop/tests/views/SettingsView/settingsAiServicesLayout.test.ts
@@ -236,7 +236,7 @@ describe('SettingsAiServicesSection', () => {
expect(wrapper.text()).toContain('Renamed Gateway');
});
- it('patches the default model locally without provider list reload', async () => {
+ it('patches model edits locally without provider list reload', async () => {
const provider = {
id: 2,
name: 'Custom Gateway',
@@ -288,14 +288,14 @@ describe('SettingsAiServicesSection', () => {
ProviderList: true,
ProviderConfig: true,
ModelList: {
- props: ['defaultModelId'],
- emits: ['set-default'],
+ props: ['entryModelId'],
+ emits: ['update'],
template: `
`,
},
@@ -311,14 +311,16 @@ describe('SettingsAiServicesSection', () => {
await flushPromises();
- expect(wrapper.get('[data-testid="set-default-model"]').text()).toContain('default: 10');
+ expect(wrapper.get('[data-testid="update-model"]').text()).toContain('entry: 10');
- await wrapper.get('[data-testid="set-default-model"]').trigger('click');
+ await wrapper.get('[data-testid="update-model"]').trigger('click');
await flushPromises();
- expect(queries.setDefaultModel).toHaveBeenCalledWith({ modelId: 20 });
+ expect(queries.updateModel).toHaveBeenCalledWith({
+ id: 20,
+ modelPatch: { name: 'Renamed Model' },
+ });
expect(queries.findAllProvidersSorted).toHaveBeenCalledTimes(1);
- expect(alertMock.success).toHaveBeenCalledWith('设置成功');
- expect(wrapper.get('[data-testid="set-default-model"]').text()).toContain('default: 20');
+ expect(alertMock.success).toHaveBeenCalledWith('保存成功');
});
});
diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts
index 05b0bd40..cf316d3f 100644
--- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts
+++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts
@@ -14,6 +14,7 @@ const settingsStoreMock = vi.hoisted(() => ({
outputScrollBehavior: 'follow_output',
searchWindowSizePreset: 'normal',
searchWindowDefaultSize: { width: 720, height: 520 },
+ allowModelAutoSwitch: false,
appUpdateChannel: 'stable',
appUpdateAutoCheck: true,
appUpdateLastCheckedAt: null,
@@ -25,6 +26,7 @@ const settingsStoreMock = vi.hoisted(() => ({
updateStartMinimized: vi.fn().mockResolvedValue(undefined),
updateOutputScrollBehavior: vi.fn().mockResolvedValue(undefined),
updateSearchWindowSizePreset: vi.fn().mockResolvedValue(undefined),
+ updateAllowModelAutoSwitch: vi.fn().mockResolvedValue(undefined),
updateLanguage: vi.fn().mockResolvedValue(undefined),
updateAppUpdateChannel: vi.fn().mockResolvedValue(undefined),
updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined),
@@ -152,6 +154,8 @@ describe('SettingsGeneralSection', () => {
appUpdateServiceMock.state = appUpdateServiceMock.createState();
nativeMock.shortcut.getShortcutStatus.mockResolvedValue([false, null]);
nativeMock.autostart.isAutostartEnabled.mockResolvedValue(false);
+ settingsStoreMock.settings.value.allowModelAutoSwitch = false;
+ settingsStoreMock.updateAllowModelAutoSwitch.mockResolvedValue(undefined);
});
it('renders the general settings groups and row controls', () => {
@@ -187,7 +191,7 @@ describe('SettingsGeneralSection', () => {
expect(controls.length).toBeGreaterThanOrEqual(3);
const rowLabels = wrapper.findAll('[data-testid="settings-general-row-label"]');
- expect(rowLabels).toHaveLength(8);
+ expect(rowLabels).toHaveLength(9);
});
it('shows the current version in the latest update details', async () => {
@@ -229,6 +233,22 @@ describe('SettingsGeneralSection', () => {
expect(appUpdateServiceMock.setAutoCheckEnabled).toHaveBeenCalledWith(false);
});
+ it('reverts the automatic model switch toggle when saving fails', async () => {
+ settingsStoreMock.updateAllowModelAutoSwitch.mockRejectedValueOnce(
+ new Error('write failed')
+ );
+ const wrapper = mount(GeneralSection);
+
+ await flushPromises();
+ await wrapper
+ .get('[data-testid="settings-allow-model-auto-switch-toggle"]')
+ .trigger('click');
+ await flushPromises();
+
+ expect(settingsStoreMock.updateAllowModelAutoSwitch).toHaveBeenCalledWith(true);
+ expect(settingsStoreMock.settings.value.allowModelAutoSwitch).toBe(false);
+ });
+
it('shows update details and delegates update actions', async () => {
appUpdateServiceMock.state = {
...appUpdateServiceMock.state,
diff --git a/apps/desktop/tests/views/SettingsView/settingsNavigation.test.ts b/apps/desktop/tests/views/SettingsView/settingsNavigation.test.ts
index e70d7ff5..29a02df2 100644
--- a/apps/desktop/tests/views/SettingsView/settingsNavigation.test.ts
+++ b/apps/desktop/tests/views/SettingsView/settingsNavigation.test.ts
@@ -15,6 +15,7 @@ describe('settingsNavigation', () => {
expect(items.map((item) => item.label)).toEqual([
'通用',
'服务商与模型',
+ '模型设置',
'内置工具',
'MCP 工具',
'数据管理',
@@ -30,6 +31,10 @@ describe('settingsNavigation', () => {
expect(getSettingsNavigationItem('ai-services')).toMatchObject({
label: '服务商与模型',
+ icon: 'cloud',
+ });
+ expect(getSettingsNavigationItem('model-preferences')).toMatchObject({
+ label: '模型设置',
icon: 'llm',
});
expect(getSettingsNavigationItem('general')?.description).toContain('快捷键');
diff --git a/eslint.config.js b/eslint.config.js
index 44e101b6..9f02deb7 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -60,6 +60,10 @@ export default defineConfig([
'.coverage/**',
'**/.coverage',
'**/.coverage/**',
+ '.vite-cache',
+ '.vite-cache/**',
+ '**/.vite-cache',
+ '**/.vite-cache/**',
'src-tauri',
'src-tauri/**',
'**/src-tauri',
From 77ec7786e6ec113ef7e649f92054639f3d5b8e94 Mon Sep 17 00:00:00 2001
From: KagurazakaSakuya <2471012170@qq.com>
Date: Sat, 30 May 2026 23:16:39 +0900
Subject: [PATCH 2/6] fix: address model preference review feedback
---
.../AgentService/prompt/modelPreferences.ts | 2 +-
.../General/components/ModelPreferences.vue | 2 +-
.../SettingsView/components/General/index.vue | 15 +++++++++++
.../prompt/model-preferences.test.ts | 1 +
.../settingsGeneralComponent.test.ts | 26 +++++++++++++++++++
5 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/apps/desktop/src/services/AgentService/prompt/modelPreferences.ts b/apps/desktop/src/services/AgentService/prompt/modelPreferences.ts
index 1ca740b3..81c8e83a 100644
--- a/apps/desktop/src/services/AgentService/prompt/modelPreferences.ts
+++ b/apps/desktop/src/services/AgentService/prompt/modelPreferences.ts
@@ -82,7 +82,7 @@ export async function buildModelPreferencesPrompt(): Promise {
'',
'Use `builtin__upgrade_model` with `{ "role": "fast" }` for simple questions or lightweight utility tasks.',
'Use `builtin__upgrade_model` with `{ "role": "general" }` for complex tasks when no custom scenario below clearly matches.',
- 'Use `builtin__upgrade_model` with `{ "restore": true }`, `{ "role": "entry" }`, or `{ "scenario": null }` to return to the entry model.',
+ 'Use `builtin__upgrade_model` with `{ "restore": true }` or `{ "role": "entry" }` to return to the entry model.',
'',
'You can switch to a specialized model when the task clearly matches one of these user-configured scenarios.',
'Call `builtin__upgrade_model` with `{ "scenario": "" }` to switch to that scenario model.',
diff --git a/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue b/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
index b9f5fa07..0c688ee7 100644
--- a/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
+++ b/apps/desktop/src/views/SettingsView/components/General/components/ModelPreferences.vue
@@ -211,7 +211,7 @@
const modelId = Number(nextForm.modelId);
const selectedModel = Number.isFinite(modelId)
- ? models.value.find((model) => model.id === modelId)
+ ? models.value.find((model) => model.id === modelId && model.provider_enabled === 1)
: null;
if (!selectedModel) {
alert.error(t('settings.general.modelPreferences.modelRequired'));
diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue
index b39c109f..839e4616 100644
--- a/apps/desktop/src/views/SettingsView/components/General/index.vue
+++ b/apps/desktop/src/views/SettingsView/components/General/index.vue
@@ -76,6 +76,7 @@
const shortcutInput = ref(null);
const isSaving = ref(false);
+ const savingAllowModelAutoSwitch = ref(false);
const isCapturing = ref(false);
const hasCapturedShortcut = ref(false);
const displayShortcut = ref('');
@@ -362,6 +363,11 @@
};
const saveAllowModelAutoSwitch = async (previousValue: boolean) => {
+ if (savingAllowModelAutoSwitch.value) {
+ return;
+ }
+
+ savingAllowModelAutoSwitch.value = true;
try {
await settingsStore.updateAllowModelAutoSwitch(settings.value.allowModelAutoSwitch);
alertMessage.value?.success(t('common.saved'), 2000);
@@ -369,10 +375,16 @@
settings.value.allowModelAutoSwitch = previousValue;
console.error('Failed to save allow_model_auto_switch setting:', error);
alertMessage.value?.error(t('settings.general.saveSettingsFailed'), 3000);
+ } finally {
+ savingAllowModelAutoSwitch.value = false;
}
};
const toggleAllowModelAutoSwitch = () => {
+ if (savingAllowModelAutoSwitch.value) {
+ return;
+ }
+
const previousValue = settings.value.allowModelAutoSwitch;
settings.value.allowModelAutoSwitch = !previousValue;
void saveAllowModelAutoSwitch(previousValue);
@@ -668,6 +680,7 @@