From 08ae31422a8a5c6967191bd3a6ff26c79d63bba7 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 14 Feb 2026 16:33:50 +0800 Subject: [PATCH 1/3] docs: add spec for default model setting --- docs/specs/default-model-settings/plan.md | 127 +++++++++++++++++++++ docs/specs/default-model-settings/spec.md | 89 +++++++++++++++ docs/specs/default-model-settings/tasks.md | 57 +++++++++ 3 files changed, 273 insertions(+) create mode 100644 docs/specs/default-model-settings/plan.md create mode 100644 docs/specs/default-model-settings/spec.md create mode 100644 docs/specs/default-model-settings/tasks.md diff --git a/docs/specs/default-model-settings/plan.md b/docs/specs/default-model-settings/plan.md new file mode 100644 index 000000000..5c258f9c0 --- /dev/null +++ b/docs/specs/default-model-settings/plan.md @@ -0,0 +1,127 @@ +# 默认模型与默认视觉模型实施计划 + +## 1. 当前实现基线 + +### 1.1 新建会话模型来源(现状) + +1. `src/renderer/src/components/NewThread.vue` 初始化模型时,优先“最近会话/偏好模型/第一个可用模型”。 +2. `src/main/presenter/sessionPresenter/managers/conversationManager.ts` 在 `createConversation()` 中默认继承最近会话 `settings`。 +3. 因此当前“新建会话默认模型”不稳定,会受最近会话影响。 + +### 1.2 imageServer 模型来源(现状) + +1. `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts` 构造函数接收 `provider/model`。 +2. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` 通过 `new ImageServer(args[0], args[1])` 传入。 +3. `src/renderer/src/components/mcp-config/mcpServerForm.vue` 存在 `imageServer` 专属模型选择 UI,并把选择写入 server `args`。 + +## 2. 设计决策 + +### 2.1 设置数据结构 + +新增两个设置键(存储于 `app-settings`): + +1. `defaultModel: { providerId: string; modelId: string }` +2. `defaultVisionModel: { providerId: string; modelId: string }` + +说明: + +1. 两者均通过现有 `configPresenter.getSetting/setSetting` 访问。 +2. 不新增独立 store 文件,先沿用现有配置存储体系。 + +### 2.2 新建会话默认模型决策 + +会话创建链路分两层处理: + +1. **Renderer 层(UI 体验)**:`NewThread.vue` 初始化时优先读 `defaultModel`(非 ACP)。 +2. **Main 层(最终兜底)**:`conversationManager.createConversation` 在调用方未显式传 `providerId/modelId` 时应用 `defaultModel`(非 ACP)。 + +规则: + +1. 显式传入 `providerId/modelId` 时不覆盖。 +2. `chatMode === 'acp agent'` 或目标 provider 为 `acp` 时不应用 `defaultModel`。 +3. `defaultModel` 未配置或无效时,回退到现有逻辑(保持兼容)。 + +### 2.3 默认视觉模型决策 + +1. `defaultVisionModel` 选择器只展示 `vision=true` 的已启用模型。 +2. 保存时做前置校验(非视觉模型不可保存)。 +3. `imageServer` 运行时读取 `defaultVisionModel`;不再依赖 `args`。 + +### 2.4 imageServer 架构调整 + +目标模块: + +1. `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts` +2. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` +3. `src/renderer/src/components/mcp-config/mcpServerForm.vue` + +调整方式: + +1. `ImageServer` 构造函数去掉 provider/model 参数。 +2. 每次视觉调用时动态读取 `defaultVisionModel` 并校验可用性。 +3. `mcpServerForm.vue` 删除 `imageServer` 专属模型选择与 args 反解析逻辑。 +4. `builder.ts` 改为 `new ImageServer()`。 + +### 2.5 兼容与迁移策略 + +1. 保留旧 `imageServer.args` 数据但不再使用(兼容读取,不破坏旧配置文件结构)。 +2. 不做强制迁移脚本;缺失 `defaultVisionModel` 时由运行时错误提示引导用户配置。 + +## 3. 实施阶段 + +### Phase 1:配置与类型接入 + +1. 新增 `defaultModel/defaultVisionModel` 的读写与默认空值处理。 +2. 补充必要类型定义(若现有类型未覆盖)。 + +### Phase 2:新建会话默认模型 + +1. 调整 `NewThread.vue` 初始化优先级(`defaultModel` 优先)。 +2. 调整 `conversationManager.createConversation` 的兜底模型决策。 +3. 校验 `fork` 路径未被覆盖。 + +### Phase 3:默认视觉模型与 imageServer + +1. 设置页新增 `defaultVisionModel` 选择项(vision-only)。 +2. 移除 `mcpServerForm.vue` 中 `imageServer` 模型配置 UI 与 args 绑定逻辑。 +3. `imageServer` 改为全局读取 `defaultVisionModel`。 +4. `builder.ts` 去除 `args[0]/args[1]` 注入。 + +### Phase 4:验证与收尾 + +1. 回归新建会话路径(UI 创建、主进程创建)。 +2. 回归 `imageServer` 调用成功与失败场景。 +3. 统一补 i18n 文案与错误提示。 + +## 4. 测试策略 + +### 4.1 Main 测试 + +1. `createConversation`:无显式模型时应用 `defaultModel`。 +2. `createConversation`:ACP 模式不应用 `defaultModel`。 +3. `forkConversation`:继承行为不变。 +4. `imageServer`:读取 `defaultVisionModel` 成功/缺失/无效分支。 + +### 4.2 Renderer 测试 + +1. `NewThread` 初始化模型优先级验证(`defaultModel` 优先)。 +2. 设置页视觉模型选择仅展示 vision 模型。 +3. `mcpServerForm` 不再展示 `imageServer` 模型选择控件。 + +## 5. 风险与缓解 + +1. 风险:部分隐式创建会话路径未经过 UI,仍可能走旧默认。 +缓解:在 `conversationManager.createConversation` 做主进程兜底。 + +2. 风险:用户升级后未配置 `defaultVisionModel` 导致 imageServer 报错。 +缓解:统一错误文案,明确引导至设置页。 + +3. 风险:`defaultModel` 与 `preferredModel` 语义冲突。 +缓解:明确优先级为 `defaultModel > preferredModel`(仅非 ACP)。 + +## 6. 质量门槛 + +1. `pnpm run format` +2. `pnpm run lint` +3. `pnpm run typecheck` +4. 关键 main/renderer 测试通过 diff --git a/docs/specs/default-model-settings/spec.md b/docs/specs/default-model-settings/spec.md new file mode 100644 index 000000000..289bcd901 --- /dev/null +++ b/docs/specs/default-model-settings/spec.md @@ -0,0 +1,89 @@ +# 默认模型与默认视觉模型规格 + +## 概述 + +新增两个全局设置项: + +1. `默认模型`(`defaultModel`) +2. `默认视觉模型`(`defaultVisionModel`) + +其中: + +1. `默认模型`用于所有“新建会话”默认模型选择(`fork` 例外,`acp` 模式例外)。 +2. `默认视觉模型`用于视觉场景,当前仅供内置 `imageServer` 使用。 +3. `imageServer` 现有的“按服务器 args 配置模型”能力移除,统一改为读取全局 `defaultVisionModel`。 +4. `默认视觉模型`只能选择具备 `vision` 能力的模型。 + +## 背景与动机 + +1. 当前新建会话模型会受到“最近会话/偏好模型”影响,缺少稳定的全局默认入口。 +2. `imageServer` 以 MCP 服务器局部参数维护模型,配置分散,和全局模型管理不一致。 +3. 视觉模型应统一做能力约束(`vision=true`),避免运行时才发现模型不支持图像输入。 + +## 用户故事 + +### US-1:新建会话统一默认模型 + +作为用户,我希望设置一次“默认模型”,以后新建会话时自动使用它,而不是被最近会话模型影响。 + +### US-2:ACP 模式不受影响 + +作为用户,我希望 ACP 会话仍按 ACP 机制选模型,不被“默认模型”覆盖。 + +### US-3:视觉能力统一入口 + +作为用户,我希望设置一个“默认视觉模型”,内置图片工具直接使用它,不再在 `imageServer` 里重复配置。 + +## 功能需求 + +### A. 新增全局设置项 + +- [ ] 新增 `defaultModel` 配置,数据结构为 `{ providerId: string, modelId: string }` +- [ ] 新增 `defaultVisionModel` 配置,数据结构为 `{ providerId: string, modelId: string }` +- [ ] 两项配置均通过 `configPresenter.getSetting/setSetting` 读写并持久化 + +### B. 新建会话默认模型规则 + +- [ ] 适用范围:所有“新建会话”路径(即调用 `createConversation` 创建新会话) +- [ ] 排除范围:`forkConversation`(以及基于分支语义的会话继承路径)不改,继续继承源会话模型 +- [ ] ACP 例外:当会话处于 `acp agent` 模式时,不应用 `defaultModel` +- [ ] 优先级:当调用方未显式传入 `providerId/modelId` 时,`defaultModel` 优先于“最近会话/旧偏好模型”逻辑 +- [ ] 当 `defaultModel` 未配置或已失效时,回退到当前现有兜底策略 + +### C. 默认视觉模型规则 + +- [ ] `defaultVisionModel` 的候选列表仅允许 `vision=true` 的已启用模型 +- [ ] 若用户尝试保存非视觉模型,需阻止并给出明确提示 +- [ ] 若 `defaultVisionModel` 未配置或失效,视觉调用返回可读错误并引导去设置页配置 + +### D. imageServer 统一使用全局视觉模型 + +- [ ] `imageServer` 不再从 MCP server `args` 读取 provider/model +- [ ] `imageServer` 每次视觉调用前从全局配置读取 `defaultVisionModel` +- [ ] `inMemoryServers/builder.ts` 中 `imageServer` 构造不再依赖 `args[0]/args[1]` +- [ ] MCP 配置表单中针对 `imageServer` 的模型选择 UI 移除 + +### E. 验收标准 + +- [ ] 在非 ACP 新建会话中,未手动改模型时默认使用 `defaultModel` +- [ ] `fork` 新会话继续继承原会话模型,不受 `defaultModel` 干预 +- [ ] ACP 新建会话不受 `defaultModel` 影响 +- [ ] `imageServer` 在已配置 `defaultVisionModel` 时可正常调用视觉能力 +- [ ] `imageServer` 在未配置/配置无效时给出明确错误(非静默失败) +- [ ] `imageServer` 相关 MCP args 模型配置入口已移除 + +## 非目标 + +1. 不改标题生成链路的模型选择策略(本次仅新增会话默认模型与视觉默认模型)。 +2. 不新增“按工具分别配置视觉模型”的能力(仅一个全局视觉模型)。 +3. 不修改 ACP 模型管理机制。 + +## 约束 + +1. 保持现有 Presenter 架构与 IPC 类型边界,不引入新通信通道。 +2. 保持设置持久化兼容,旧配置文件可继续加载。 +3. UI 文案必须走 i18n。 + +## 开放问题 + +无。 diff --git a/docs/specs/default-model-settings/tasks.md b/docs/specs/default-model-settings/tasks.md new file mode 100644 index 000000000..ff0926878 --- /dev/null +++ b/docs/specs/default-model-settings/tasks.md @@ -0,0 +1,57 @@ +# 默认模型与默认视觉模型 Tasks + +## T0 规格确认 + +- [x] 完成 `spec.md` +- [x] 完成 `plan.md` +- [x] 完成 `tasks.md` + +## T1 配置层 + +- [ ] 在配置体系新增 `defaultModel` 设置读写(`providerId/modelId`)。 +- [ ] 在配置体系新增 `defaultVisionModel` 设置读写(`providerId/modelId`)。 +- [ ] 为设置页提供读取/保存接口(复用 `configPresenter.getSetting/setSetting`)。 + +## T2 新建会话默认模型(Renderer + Main) + +- [ ] 调整 `src/renderer/src/components/NewThread.vue` 初始化模型优先级:非 ACP 时优先 `defaultModel`。 +- [ ] 保持手动选模可覆盖默认值(仅默认初始化受影响)。 +- [ ] 在 `src/main/presenter/sessionPresenter/managers/conversationManager.ts` 中补主进程兜底:未显式模型且非 ACP 时应用 `defaultModel`。 +- [ ] 验证主进程自动建会话入口(如 in-memory server 调用 `createConversation`)同样生效。 +- [ ] 验证 `forkConversation` 路径不受影响。 + +## T3 设置页 UI + +- [ ] 在设置页新增“默认模型”选择项(全模型,排除 ACP provider)。 +- [ ] 在设置页新增“默认视觉模型”选择项(仅 `vision=true`)。 +- [ ] 补齐 i18n 文案(至少 `zh-CN` + `en-US`)。 +- [ ] 视觉模型保存时增加校验与错误提示。 + +## T4 imageServer 改造 + +- [ ] 修改 `src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts`:移除构造注入 provider/model,改为运行时读取 `defaultVisionModel`。 +- [ ] 修改 `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts`:`imageServer` 改为无参构造。 +- [ ] 修改 `src/renderer/src/components/mcp-config/mcpServerForm.vue`:删除 `imageServer` 模型选择 UI、args 反解析与写回逻辑。 +- [ ] 保持其他 inmemory server 的 args 行为不变。 + +## T5 失败处理与提示 + +- [ ] `defaultVisionModel` 缺失时,`imageServer` 返回可读错误。 +- [ ] `defaultVisionModel` 指向非视觉或不可用模型时,`imageServer` 返回可读错误。 +- [ ] 错误提示文案包含“去设置中配置默认视觉模型”。 + +## T6 测试 + +- [ ] Main:`createConversation` 非 ACP 默认模型应用测试。 +- [ ] Main:ACP 场景不应用默认模型测试。 +- [ ] Main:`forkConversation` 不受影响测试。 +- [ ] Main:`imageServer` 读取 `defaultVisionModel` 成功/失败测试。 +- [ ] Renderer:默认视觉模型只展示 vision 模型测试。 +- [ ] Renderer:`mcpServerForm` 不再出现 `imageServer` 专属模型配置测试。 + +## T7 质量检查 + +- [ ] `pnpm run format` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 跑相关测试并记录结果 From 12193a968c826ef61126d853dac57941580e5820 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 14 Feb 2026 20:28:27 +0800 Subject: [PATCH 2/3] feat: #1174 support default models setting --- .../agentPresenter/utility/utilityHandler.ts | 43 ++- src/main/presenter/configPresenter/index.ts | 18 ++ .../mcpPresenter/inMemoryServers/builder.ts | 2 +- .../inMemoryServers/imageServer.ts | 40 ++- src/main/presenter/sessionPresenter/index.ts | 42 ++- .../managers/conversationManager.ts | 15 + .../settings/components/CommonSettings.vue | 2 + .../common/DefaultModelSettingsSection.vue | 286 ++++++++++++++++++ src/renderer/src/components/ModelSelect.vue | 18 +- src/renderer/src/components/NewThread.vue | 20 ++ .../components/mcp-config/mcpServerForm.vue | 114 ++++--- src/renderer/src/i18n/da-DK/settings.json | 7 +- src/renderer/src/i18n/en-US/settings.json | 7 +- src/renderer/src/i18n/fa-IR/settings.json | 7 +- src/renderer/src/i18n/fr-FR/settings.json | 7 +- src/renderer/src/i18n/he-IL/settings.json | 7 +- src/renderer/src/i18n/ja-JP/settings.json | 7 +- src/renderer/src/i18n/ko-KR/settings.json | 7 +- src/renderer/src/i18n/pt-BR/settings.json | 7 +- src/renderer/src/i18n/ru-RU/settings.json | 7 +- src/renderer/src/i18n/zh-CN/settings.json | 7 +- src/renderer/src/i18n/zh-HK/settings.json | 7 +- src/renderer/src/i18n/zh-TW/settings.json | 7 +- .../types/presenters/legacy.presenters.d.ts | 6 + .../defaultModelSettings.test.ts | 71 +++++ 25 files changed, 659 insertions(+), 102 deletions(-) create mode 100644 src/renderer/settings/components/common/DefaultModelSettingsSection.vue create mode 100644 test/main/presenter/configPresenter/defaultModelSettings.test.ts diff --git a/src/main/presenter/agentPresenter/utility/utilityHandler.ts b/src/main/presenter/agentPresenter/utility/utilityHandler.ts index e107edd80..ccd2e6b51 100644 --- a/src/main/presenter/agentPresenter/utility/utilityHandler.ts +++ b/src/main/presenter/agentPresenter/utility/utilityHandler.ts @@ -265,11 +265,44 @@ export class UtilityHandler extends BaseHandler { } }) .filter((item) => item.formattedMessage.content.length > 0) - const title = await this.ctx.llmProviderPresenter.summaryTitles( - messagesWithLength.map((item) => item.formattedMessage), - conversation.settings.providerId, - conversation.settings.modelId - ) + const assistantModel = this.ctx.configPresenter.getSetting<{ + providerId: string + modelId: string + }>('assistantModel') + const fallbackProviderId = conversation.settings.providerId + const fallbackModelId = conversation.settings.modelId + const preferredProviderId = assistantModel?.providerId || fallbackProviderId + const preferredModelId = assistantModel?.modelId || fallbackModelId + + let title: string + try { + title = await this.ctx.llmProviderPresenter.summaryTitles( + messagesWithLength.map((item) => item.formattedMessage), + preferredProviderId, + preferredModelId + ) + } catch (error) { + const shouldFallback = + preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId + if (!shouldFallback) { + throw error + } + console.warn( + '[UtilityHandler] Failed to generate title with assistant model, fallback to conversation model', + { + preferredProviderId, + preferredModelId, + fallbackProviderId, + fallbackModelId, + error + } + ) + title = await this.ctx.llmProviderPresenter.summaryTitles( + messagesWithLength.map((item) => item.formattedMessage), + fallbackProviderId, + fallbackModelId + ) + } let cleanedTitle = title.replace(/.*?<\/think>/g, '').trim() cleanedTitle = cleanedTitle.replace(/^/, '').trim() return cleanedTitle diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index c77ea6c67..38e7fb88b 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -87,6 +87,8 @@ interface IAppSettings { skillsPath?: string // Skills directory path enableSkills?: boolean // Skills system global toggle hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings + defaultModel?: { providerId: string; modelId: string } // Default model for new conversations + defaultVisionModel?: { providerId: string; modelId: string } // Default vision model for image tools [key: string]: unknown // Allow arbitrary keys, using unknown type instead of any } @@ -1818,6 +1820,22 @@ export class ConfigPresenter implements IConfigPresenter { getConfirmoHookStatus(): { available: boolean; path: string } { return presenter.hooksNotifications.getConfirmoHookStatus() } + + getDefaultModel(): { providerId: string; modelId: string } | undefined { + return this.getSetting<{ providerId: string; modelId: string }>('defaultModel') + } + + setDefaultModel(model: { providerId: string; modelId: string } | undefined): void { + this.setSetting('defaultModel', model) + } + + getDefaultVisionModel(): { providerId: string; modelId: string } | undefined { + return this.getSetting<{ providerId: string; modelId: string }>('defaultVisionModel') + } + + setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void { + this.setSetting('defaultVisionModel', model) + } } export { defaultShortcutKey } from './shortcutKeySettings' diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts index acffaf8cf..8ba11bfb3 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts @@ -31,7 +31,7 @@ export function getInMemoryServer( case 'deepResearch': return new DeepResearchServer(env) case 'imageServer': - return new ImageServer(args[0], args[1]) + return new ImageServer(args[0] || undefined, args[1] || undefined) case 'powerpack': return new PowerpackServer(env) case 'difyKnowledge': diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts index aaee81edb..370acb6eb 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts @@ -49,9 +49,10 @@ export class ImageServer { private provider: string private model: string - constructor(provider: string, model: string) { - this.provider = provider - this.model = model + constructor(provider?: string, model?: string) { + const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() + this.provider = provider || defaultVisionModel?.providerId || 'openai' + this.model = model || defaultVisionModel?.modelId || 'gpt-4o' this.server = new Server( { name: 'image-processing-server', @@ -71,6 +72,21 @@ export class ImageServer { // // Initialization logic, e.g., configure upload service client // } + private getEffectiveModel(): { provider: string; model: string } { + if (this.provider && this.model) { + return { provider: this.provider, model: this.model } + } + + const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() + if (defaultVisionModel?.providerId && defaultVisionModel?.modelId) { + return { provider: defaultVisionModel.providerId, model: defaultVisionModel.modelId } + } + + throw new Error( + 'No vision model configured. Please set a default vision model in Settings > Common > Default Model.' + ) + } + public startServer(transport: Transport): void { this.server.connect(transport) } @@ -94,9 +110,10 @@ export class ImageServer { fileBuffer: Buffer, prompt: string ): Promise { + const { provider, model } = this.getEffectiveModel() // TODO: Implement actual API call to a multimodal model (e.g., GPT-4o, Gemini) console.log( - `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${this.provider}/${this.model} with prompt: "${prompt}"...` + `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model} with prompt: "${prompt}"...` ) // Construct the messages array for the multimodal model @@ -117,13 +134,13 @@ export class ImageServer { } ] - const modelConfig = presenter.configPresenter.getModelConfig(this.model, this.provider) + const modelConfig = presenter.configPresenter.getModelConfig(model, provider) try { const response = await presenter.llmproviderPresenter.generateCompletionStandalone( - this.provider, + provider, messages, - this.model, + model, modelConfig?.temperature ?? 0.6, modelConfig?.maxTokens || 1000 ) @@ -139,9 +156,10 @@ export class ImageServer { } private async ocrImageWithModel(filePath: string, fileBuffer: Buffer): Promise { + const { provider, model } = this.getEffectiveModel() // TODO: Implement actual API call to an OCR service or a multimodal model capable of OCR console.log( - `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${this.provider}/${this.model}...` + `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model}...` ) // Construct the messages array for the multimodal model @@ -164,13 +182,13 @@ export class ImageServer { console.log(messages) - const modelConfig = presenter.configPresenter.getModelConfig(this.model) + const modelConfig = presenter.configPresenter.getModelConfig(model, provider) try { const ocrText = await presenter.llmproviderPresenter.generateCompletionStandalone( - this.provider, + provider, messages, - this.model, + model, modelConfig?.temperature ?? 0.6, modelConfig?.maxTokens || 1000 ) diff --git a/src/main/presenter/sessionPresenter/index.ts b/src/main/presenter/sessionPresenter/index.ts index 73d1e2e42..50bcd9b6b 100644 --- a/src/main/presenter/sessionPresenter/index.ts +++ b/src/main/presenter/sessionPresenter/index.ts @@ -33,6 +33,7 @@ export class SessionPresenter implements ISessionPresenter { private sqlitePresenter: ISQLitePresenter private messageManager: MessageManager private llmProviderPresenter: ILlmProviderPresenter + private configPresenter: IConfigPresenter private conversationManager: ConversationManager private exporter: IConversationExporter private commandPermissionService: CommandPermissionService @@ -49,6 +50,7 @@ export class SessionPresenter implements ISessionPresenter { this.sqlitePresenter = options.sqlitePresenter this.messageManager = options.messageManager ?? new MessageManager(options.sqlitePresenter) this.llmProviderPresenter = options.llmProviderPresenter + this.configPresenter = options.configPresenter this.exporter = options.exporter this.commandPermissionService = options.commandPermissionService ?? new CommandPermissionService() @@ -258,11 +260,43 @@ export class SessionPresenter implements ISessionPresenter { }) .filter((item) => item.content.length > 0) - const title = await this.llmProviderPresenter.summaryTitles( - formattedMessages, - conversation.settings.providerId, - conversation.settings.modelId + const assistantModel = this.configPresenter.getSetting<{ providerId: string; modelId: string }>( + 'assistantModel' ) + const fallbackProviderId = conversation.settings.providerId + const fallbackModelId = conversation.settings.modelId + const preferredProviderId = assistantModel?.providerId || fallbackProviderId + const preferredModelId = assistantModel?.modelId || fallbackModelId + + let title: string + try { + title = await this.llmProviderPresenter.summaryTitles( + formattedMessages, + preferredProviderId, + preferredModelId + ) + } catch (error) { + const shouldFallback = + preferredProviderId !== fallbackProviderId || preferredModelId !== fallbackModelId + if (!shouldFallback) { + throw error + } + console.warn( + '[SessionPresenter] Failed to generate title with assistant model, fallback to conversation model', + { + preferredProviderId, + preferredModelId, + fallbackProviderId, + fallbackModelId, + error + } + ) + title = await this.llmProviderPresenter.summaryTitles( + formattedMessages, + fallbackProviderId, + fallbackModelId + ) + } let cleanedTitle = title.replace(/.*?<\/think>/g, '').trim() cleanedTitle = cleanedTitle.replace(/^/, '').trim() diff --git a/src/main/presenter/sessionPresenter/managers/conversationManager.ts b/src/main/presenter/sessionPresenter/managers/conversationManager.ts index 24d97c428..9df3ffb30 100644 --- a/src/main/presenter/sessionPresenter/managers/conversationManager.ts +++ b/src/main/presenter/sessionPresenter/managers/conversationManager.ts @@ -185,6 +185,21 @@ export class ConversationManager { defaultSettings.activeSkills = [] } + // Apply global defaultModel if caller didn't specify model and no recent conversation settings + const shouldApplyDefaultModel = + !settings.modelId && + !settings.providerId && + !latestConversation?.settings && + !defaultSettings.acpWorkdirMap?.['default'] + + if (shouldApplyDefaultModel) { + const globalDefaultModel = this.configPresenter.getDefaultModel() + if (globalDefaultModel?.modelId && globalDefaultModel?.providerId) { + defaultSettings.modelId = globalDefaultModel.modelId + defaultSettings.providerId = globalDefaultModel.providerId + } + } + const sanitizedSettings: Partial = { ...settings } Object.keys(sanitizedSettings).forEach((key) => { const typedKey = key as keyof CONVERSATION_SETTINGS diff --git a/src/renderer/settings/components/CommonSettings.vue b/src/renderer/settings/components/CommonSettings.vue index 9e64ffa6d..ad0ed5a1d 100644 --- a/src/renderer/settings/components/CommonSettings.vue +++ b/src/renderer/settings/components/CommonSettings.vue @@ -2,6 +2,7 @@
+ +
+
+ + {{ t('settings.common.defaultModel.title') }} +
+ +
+ {{ + t('settings.common.searchAssistantModel') + }} +
+ + + + + + + + +
+
+ +
+ {{ + t('settings.common.defaultModel.chatModel') + }} +
+ + + + + + + + +
+
+ +
+ {{ + t('settings.common.defaultModel.visionModel') + }} +
+ + + + + + + + +
+
+
+ + + diff --git a/src/renderer/src/components/ModelSelect.vue b/src/renderer/src/components/ModelSelect.vue index 4ee01e73f..065906c85 100644 --- a/src/renderer/src/components/ModelSelect.vue +++ b/src/renderer/src/components/ModelSelect.vue @@ -77,6 +77,10 @@ const props = defineProps({ excludeProviders: { type: Array as PropType, default: () => [] + }, + visionOnly: { + type: Boolean, + default: false } }) const providers = computed(() => { @@ -104,12 +108,14 @@ const providers = computed(() => { return { id: provider.id, name: provider.name, - models: - !props.type || props.type.length === 0 - ? enabledProvider.models - : enabledProvider.models.filter( - (model) => model.type !== undefined && props.type!.includes(model.type as ModelType) - ) + models: enabledProvider.models.filter((model) => { + const matchType = + !props.type || + props.type.length === 0 || + (model.type !== undefined && props.type.includes(model.type as ModelType)) + const matchVision = !props.visionOnly || Boolean(model.vision) + return matchType && matchVision + }) } }) .filter( diff --git a/src/renderer/src/components/NewThread.vue b/src/renderer/src/components/NewThread.vue index b1d670c8f..d3bf02aad 100644 --- a/src/renderer/src/components/NewThread.vue +++ b/src/renderer/src/components/NewThread.vue @@ -316,6 +316,26 @@ const syncModelWithMode = (mode: ChatMode, persistPreference = false) => { const initActiveModel = async () => { if (initialized.value) return const currentMode = chatMode.currentMode.value + + // 0) 非 ACP 模式下,优先使用 defaultModel + if (currentMode !== 'acp agent') { + try { + const defaultModel = (await configPresenter.getSetting('defaultModel')) as + | { providerId: string; modelId: string } + | undefined + if (defaultModel?.modelId && defaultModel?.providerId) { + const match = findEnabledModel(defaultModel.providerId, defaultModel.modelId) + if (match) { + setActiveFromEnabled({ ...match.model, providerId: match.providerId }) + initialized.value = true + return + } + } + } catch (error) { + console.warn('Failed to get default model:', error) + } + } + // 1) 尝试根据最近会话(区分 pinned/非 pinned)选择 if (chatStore.threads.length > 0) { const pinnedGroup = chatStore.threads.find((g) => g.dt === 'Pinned') diff --git a/src/renderer/src/components/mcp-config/mcpServerForm.vue b/src/renderer/src/components/mcp-config/mcpServerForm.vue index 2c9f9a1ec..9d8f694b4 100644 --- a/src/renderer/src/components/mcp-config/mcpServerForm.vue +++ b/src/renderer/src/components/mcp-config/mcpServerForm.vue @@ -18,23 +18,18 @@ import { MCPServerConfig } from '@shared/presenter' import { EmojiPicker } from '@/components/emoji-picker' import { useToast } from '@/components/use-toast' import { Icon } from '@iconify/vue' -import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/components/ui/popover' -import { ChevronDown, X } from 'lucide-vue-next' -import ModelSelect from '@/components/ModelSelect.vue' +import { X } from 'lucide-vue-next' import ModelIcon from '@/components/icons/ModelIcon.vue' import { useModelStore } from '@/stores/modelStore' -import type { RENDERER_MODEL_META } from '@shared/presenter' import { MCP_MARKETPLACE_URL, HIGRESS_MCP_MARKETPLACE_URL } from './const' import { usePresenter } from '@/composables/usePresenter' -import { useThemeStore } from '@/stores/theme' -import { ModelType } from '@shared/model' import { nanoid } from 'nanoid' const { t } = useI18n() const { toast } = useToast() const modelStore = useModelStore() const devicePresenter = usePresenter('devicePresenter') -const themeStore = useThemeStore() +const configPresenter = usePresenter('configPresenter') const props = defineProps<{ serverName?: string initialConfig?: MCPServerConfig @@ -64,9 +59,8 @@ const customHeadersFocused = ref(false) const customHeadersDisplayValue = ref('') const npmRegistry = ref(props.initialConfig?.customNpmRegistry || '') -// 模型选择相关 -const modelSelectOpen = ref(false) -const selectedImageModel = ref(null) +// imageServer 展示用(只读,来源于 defaultVisionModel) +const selectedImageModelName = ref('') const selectedImageModelProvider = ref('') // E2B 配置相关 @@ -94,13 +88,31 @@ const formatJsonHeaders = (headers: Record): string => { .map(([key, value]) => `${key}=${value}`) .join('\n') } -// 处理模型选择 -const handleImageModelSelect = (model: RENDERER_MODEL_META, providerId: string): void => { - selectedImageModel.value = model - selectedImageModelProvider.value = providerId - // 将provider和modelId以空格分隔拼接成args的值 - setArgsRowsFromArray([providerId, model.id]) - modelSelectOpen.value = false +const refreshImageServerDefaultModelDisplay = async (): Promise => { + if (!isImageServer.value) { + selectedImageModelName.value = '' + selectedImageModelProvider.value = '' + return + } + + const defaultVisionModel = (await configPresenter.getSetting('defaultVisionModel')) as + | { providerId: string; modelId: string } + | undefined + if (!defaultVisionModel?.providerId || !defaultVisionModel?.modelId) { + selectedImageModelName.value = '' + selectedImageModelProvider.value = '' + return + } + + selectedImageModelProvider.value = defaultVisionModel.providerId + const providerEntry = modelStore.enabledModels.find( + (entry) => entry.providerId === defaultVisionModel.providerId + ) + const resolvedModel = providerEntry?.models.find( + (model) => model.id === defaultVisionModel.modelId + ) + selectedImageModelName.value = + resolvedModel?.name || `${defaultVisionModel.providerId}/${defaultVisionModel.modelId}` } // 获取内置服务器的本地化名称和描述 @@ -490,9 +502,11 @@ const handleSubmit = (): void => { } } else { // STDIO 或 inmemory 类型的服务器 - const normalizedArgs = isBuildInFileSystem.value - ? foldersList.value.filter((folder) => folder.trim().length > 0) - : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) + const normalizedArgs = isImageServer.value + ? [] + : isBuildInFileSystem.value + ? foldersList.value.filter((folder) => folder.trim().length > 0) + : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) serverConfig = { ...baseConfig, command: command.value.trim(), @@ -607,28 +621,13 @@ watch( { immediate: true } ) -// 初始化时解析args中的provider和modelId(针对imageServer) +// imageServer 仅展示默认视觉模型,不再通过 args 配置 watch( - [() => name.value, () => argsRows.value.map((row) => row.value), () => type.value], - ([newName, newArgs, newType]) => { - if (newType === 'inmemory' && newName === 'imageServer' && newArgs.length > 0) { - // 从args中解析出provider和modelId - const argsParts = newArgs.filter((value) => value.trim().length > 0) - if (argsParts.length >= 2) { - const providerId = argsParts[0] - const modelId = argsParts[1] - // 查找对应的模型 - const foundModel = modelStore.findModelByIdOrName(modelId) - if (foundModel && foundModel.providerId === providerId) { - selectedImageModel.value = foundModel.model - selectedImageModelProvider.value = providerId - } else { - console.warn(`未找到匹配的模型: ${providerId} ${modelId}`) - } - } - } + [() => name.value, () => type.value, () => modelStore.enabledModels], + () => { + void refreshImageServerDefaultModelDisplay() }, - { immediate: true } + { immediate: true, deep: true } ) // Watch for initial config changes (primarily for edit mode) @@ -872,29 +871,18 @@ HTTP-Referer=deepchatai.cn` - - - - - - - - +
+ + {{ + selectedImageModelName || t('settings.common.selectModel') + }} +
diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 38d0cd90b..2191c144d 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "Når DeepChat ikke er i forgrunden, sendes der en systemmeddelelse, når der genereres et svar", "contentProtection": "Skærmoptagelsesbeskyttelse", "fileMaxSize": "Maksimal filstørrelse", - "fileMaxSizeHint": "Begrænser størrelsen på en enkelt uploadet fil" + "fileMaxSizeHint": "Begrænser størrelsen på en enkelt uploadet fil", + "defaultModel": { + "title": "Standardmodel", + "chatModel": "Standard chatmodel", + "visionModel": "Standard visionsmodel" + } }, "data": { "title": "Dataindstillinger", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index bebd34f89..0f87b678a 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "When DeepChat is not in the foreground, if a session is generated, a system notification will be sent", "contentProtection": "Screen capture protection", "fileMaxSize": "File Maximum Size", - "fileMaxSizeHint": "Limits the maximum size of a single uploaded file" + "fileMaxSizeHint": "Limits the maximum size of a single uploaded file", + "defaultModel": { + "title": "Default Model", + "chatModel": "Default Chat Model", + "visionModel": "Default Vision Model" + } }, "notificationsHooks": { "title": "Notifications & Hooks", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 23de2477a..199983480 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "هنگامی که دیپ‌چت در پیش‌زمینه نیست، اگر نشستی تولید شود، یک آگاه‌ساز سامانه فرستاده خواهد شد", "traceDebugEnabled": "پیگیری تماس", "fileMaxSize": "حداکثر اندازه فایل", - "fileMaxSizeHint": "حداکثر اندازه یک فایل قابل آپلود را محدود می‌کند" + "fileMaxSizeHint": "حداکثر اندازه یک فایل قابل آپلود را محدود می‌کند", + "defaultModel": { + "title": "مدل پیش‌فرض", + "chatModel": "مدل گفتگوی پیش‌فرض", + "visionModel": "مدل بینایی پیش‌فرض" + } }, "notificationsHooks": { "title": "اعلان‌ها و هوک‌ها", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 91130b8f2..5948d5766 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "Quand DeepChat est en arrière‑plan, envoyer une notification à la fin d’une réponse", "traceDebugEnabled": "Suivi des appels", "fileMaxSize": "Taille maximale du fichier", - "fileMaxSizeHint": "Limite la taille maximale d'un fichier à télécharger" + "fileMaxSizeHint": "Limite la taille maximale d'un fichier à télécharger", + "defaultModel": { + "title": "Modèle par défaut", + "chatModel": "Modèle de chat par défaut", + "visionModel": "Modèle vision par défaut" + } }, "notificationsHooks": { "title": "Notifications et hooks", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 13da3fe5e..61b4cda59 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "כאשר DeepChat אינו בחזית, אם נוצרת תשובה, תישלח התראת מערכת", "contentProtection": "הגנה מפני לכידת מסך", "fileMaxSize": "גודל קובץ מקסימלי", - "fileMaxSizeHint": "מגביל את הגודל המקסימלי של קובץ בודד המועלה" + "fileMaxSizeHint": "מגביל את הגודל המקסימלי של קובץ בודד המועלה", + "defaultModel": { + "title": "מודל ברירת מחדל", + "chatModel": "מודל צ'אט ברירת מחדל", + "visionModel": "מודל ראייה ברירת מחדל" + } }, "notificationsHooks": { "title": "התראות ו‑Hooks", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 229769b39..e6b2dbc1f 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "DeepChatがバックグラウンドのとき、応答が完了すると通知を送信します", "traceDebugEnabled": "追跡呼び出し", "fileMaxSize": "ファイルの最大サイズ", - "fileMaxSizeHint": "アップロードできるファイルの最大サイズを制限します" + "fileMaxSizeHint": "アップロードできるファイルの最大サイズを制限します", + "defaultModel": { + "title": "デフォルトモデル", + "chatModel": "デフォルトチャットモデル", + "visionModel": "デフォルト視覚モデル" + } }, "notificationsHooks": { "title": "通知とフック", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index bf1ed4fa1..dabfb3837 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "DeepChat이 전경에 있지 않으면 세션이 생성되면 시스템 알림이 전송됩니다.", "traceDebugEnabled": "추적 호출", "fileMaxSize": "파일 최대 크기", - "fileMaxSizeHint": "단일 파일 업로드의 최대 크기를 제한합니다" + "fileMaxSizeHint": "단일 파일 업로드의 최대 크기를 제한합니다", + "defaultModel": { + "title": "기본 모델", + "chatModel": "기본 채팅 모델", + "visionModel": "기본 비전 모델" + } }, "notificationsHooks": { "title": "알림 및 훅", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 12c6b148c..9906b6a6e 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -59,7 +59,12 @@ "contentProtection": "Proteção contra captura de tela", "traceDebugEnabled": "Rastreamento de Chamadas", "fileMaxSize": "Tamanho máximo do arquivo", - "fileMaxSizeHint": "Limita o tamanho máximo de um arquivo enviado" + "fileMaxSizeHint": "Limita o tamanho máximo de um arquivo enviado", + "defaultModel": { + "title": "Modelo padrão", + "chatModel": "Modelo de chat padrão", + "visionModel": "Modelo de visão padrão" + } }, "notificationsHooks": { "title": "Notificações e hooks", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 4ff0b12d4..d5ef323f4 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "Когда DeepChat не будет на переднем плане, если сгенерируется сеанс, будет отправлено системное уведомление", "traceDebugEnabled": "Функция отладки Trace", "fileMaxSize": "Максимальный размер файла", - "fileMaxSizeHint": "Ограничивает максимальный размер загружаемого файла" + "fileMaxSizeHint": "Ограничивает максимальный размер загружаемого файла", + "defaultModel": { + "title": "Модель по умолчанию", + "chatModel": "Модель чата по умолчанию", + "visionModel": "Визуальная модель по умолчанию" + } }, "notificationsHooks": { "title": "Уведомления и хуки", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index bee77a6f6..54d9fb1f5 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -59,7 +59,12 @@ "notifications": "系统通知", "notificationsDesc": "当 DeepChat 不在前台时,如有会话生成完毕会发送系统通知", "fileMaxSize": "文件最大大小", - "fileMaxSizeHint": "限制单个文件的最大上传大小" + "fileMaxSizeHint": "限制单个文件的最大上传大小", + "defaultModel": { + "title": "默认模型", + "chatModel": "默认聊天模型", + "visionModel": "默认视觉模型" + } }, "notificationsHooks": { "title": "通知与Hooks", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 24158e956..b9b89d32d 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "當 DeepChat 不在前台時,如有會話生成完畢會發送系統通知", "traceDebugEnabled": "Trace 除錯功能", "fileMaxSize": "檔案最大大小", - "fileMaxSizeHint": "限制單個檔案的最大上傳大小" + "fileMaxSizeHint": "限制單個檔案的最大上傳大小", + "defaultModel": { + "title": "預設模型", + "chatModel": "預設聊天模型", + "visionModel": "預設視覺模型" + } }, "notificationsHooks": { "title": "通知與 Hooks", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index bd7f2e71f..c3d718226 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -59,7 +59,12 @@ "notificationsDesc": "當 DeepChat 不在前台時,如有會話生成完畢會發送系統通知", "traceDebugEnabled": "Trace 除錯功能", "fileMaxSize": "檔案最大大小", - "fileMaxSizeHint": "限制單個檔案的最大上傳大小" + "fileMaxSizeHint": "限制單個檔案的最大上傳大小", + "defaultModel": { + "title": "預設模型", + "chatModel": "預設聊天模型", + "visionModel": "預設視覺模型" + } }, "notificationsHooks": { "title": "通知與 Hooks", diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index fefa5e1ef..938c6f85b 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -702,6 +702,12 @@ export interface IConfigPresenter { clearNpmRegistryCache?(): void getProviderDb(): { providers: Record } | null + // Default model settings + getDefaultModel(): { providerId: string; modelId: string } | undefined + setDefaultModel(model: { providerId: string; modelId: string } | undefined): void + getDefaultVisionModel(): { providerId: string; modelId: string } | undefined + setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void + // Atomic operation interfaces updateProviderAtomic(id: string, updates: Partial): boolean addProviderAtomic(provider: LLM_PROVIDER): void diff --git a/test/main/presenter/configPresenter/defaultModelSettings.test.ts b/test/main/presenter/configPresenter/defaultModelSettings.test.ts new file mode 100644 index 000000000..352bd039c --- /dev/null +++ b/test/main/presenter/configPresenter/defaultModelSettings.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +describe('ConfigPresenter defaultModel settings', () => { + let mockGetSetting: ReturnType + let mockSetSetting: ReturnType + let configPresenterProxy: { + getSetting: ReturnType + setSetting: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetSetting = vi.fn() + mockSetSetting = vi.fn() + configPresenterProxy = { + getSetting: mockGetSetting, + setSetting: mockSetSetting + } + }) + + describe('getDefaultModel', () => { + it('returns undefined when no default model is set', () => { + mockGetSetting.mockReturnValue(undefined) + const result = configPresenterProxy.getSetting('defaultModel') + expect(result).toBeUndefined() + }) + + it('returns the default model when set', () => { + const defaultModel = { providerId: 'openai', modelId: 'gpt-4o' } + mockGetSetting.mockReturnValue(defaultModel) + const result = configPresenterProxy.getSetting('defaultModel') + expect(result).toEqual(defaultModel) + }) + }) + + describe('setDefaultModel', () => { + it('sets the default model', () => { + const defaultModel = { providerId: 'anthropic', modelId: 'claude-3-5-sonnet-20241022' } + configPresenterProxy.setSetting('defaultModel', defaultModel) + expect(mockSetSetting).toHaveBeenCalledWith('defaultModel', defaultModel) + }) + + it('clears the default model when set to undefined', () => { + configPresenterProxy.setSetting('defaultModel', undefined) + expect(mockSetSetting).toHaveBeenCalledWith('defaultModel', undefined) + }) + }) + + describe('getDefaultVisionModel', () => { + it('returns undefined when no default vision model is set', () => { + mockGetSetting.mockReturnValue(undefined) + const result = configPresenterProxy.getSetting('defaultVisionModel') + expect(result).toBeUndefined() + }) + + it('returns the default vision model when set', () => { + const defaultVisionModel = { providerId: 'openai', modelId: 'gpt-4o' } + mockGetSetting.mockReturnValue(defaultVisionModel) + const result = configPresenterProxy.getSetting('defaultVisionModel') + expect(result).toEqual(defaultVisionModel) + }) + }) + + describe('setDefaultVisionModel', () => { + it('sets the default vision model', () => { + const defaultVisionModel = { providerId: 'google', modelId: 'gemini-2.0-flash' } + configPresenterProxy.setSetting('defaultVisionModel', defaultVisionModel) + expect(mockSetSetting).toHaveBeenCalledWith('defaultVisionModel', defaultVisionModel) + }) + }) +}) From e0f9b79aa2603fa769bc3c3512deb761d6ab49d4 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 14 Feb 2026 22:39:43 +0800 Subject: [PATCH 3/3] fix(settings): sync model selections with ACP mode and improve error handling - Add ACP mode check when applying default model in NewThread.vue - Add error handling in DefaultModelSettingsSection.vue syncModelSelections - Update imageModel i18n keys to remove 'select' verb for display-only fields --- .../settings/components/common/DefaultModelSettingsSection.vue | 2 ++ src/renderer/src/components/NewThread.vue | 2 +- src/renderer/src/components/mcp-config/mcpServerForm.vue | 2 +- src/renderer/src/i18n/da-DK/settings.json | 2 +- src/renderer/src/i18n/en-US/settings.json | 2 +- src/renderer/src/i18n/fa-IR/settings.json | 2 +- src/renderer/src/i18n/fr-FR/settings.json | 2 +- src/renderer/src/i18n/he-IL/settings.json | 2 +- src/renderer/src/i18n/ja-JP/settings.json | 2 +- src/renderer/src/i18n/ko-KR/settings.json | 2 +- src/renderer/src/i18n/pt-BR/settings.json | 2 +- src/renderer/src/i18n/ru-RU/settings.json | 2 +- src/renderer/src/i18n/zh-CN/settings.json | 2 +- src/renderer/src/i18n/zh-HK/settings.json | 2 +- src/renderer/src/i18n/zh-TW/settings.json | 2 +- 15 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/renderer/settings/components/common/DefaultModelSettingsSection.vue b/src/renderer/settings/components/common/DefaultModelSettingsSection.vue index ffeaa28ce..305ea3ac9 100644 --- a/src/renderer/settings/components/common/DefaultModelSettingsSection.vue +++ b/src/renderer/settings/components/common/DefaultModelSettingsSection.vue @@ -267,6 +267,8 @@ const syncModelSelections = async (): Promise => { await persistModelSetting('defaultModel', defaultModelSetting, chatSelection) await persistModelSetting('assistantModel', assistantModelSetting, assistantSelection) await persistModelSetting('defaultVisionModel', defaultVisionModelSetting, visionSelection) + } catch (error) { + console.error('Failed to sync model selections:', error) } finally { isSyncingModelDefaults = false } diff --git a/src/renderer/src/components/NewThread.vue b/src/renderer/src/components/NewThread.vue index d3bf02aad..877347dbf 100644 --- a/src/renderer/src/components/NewThread.vue +++ b/src/renderer/src/components/NewThread.vue @@ -325,7 +325,7 @@ const initActiveModel = async () => { | undefined if (defaultModel?.modelId && defaultModel?.providerId) { const match = findEnabledModel(defaultModel.providerId, defaultModel.modelId) - if (match) { + if (match && matchesModeProvider(match.providerId, currentMode)) { setActiveFromEnabled({ ...match.model, providerId: match.providerId }) initialized.value = true return diff --git a/src/renderer/src/components/mcp-config/mcpServerForm.vue b/src/renderer/src/components/mcp-config/mcpServerForm.vue index 9d8f694b4..1d770cfe2 100644 --- a/src/renderer/src/components/mcp-config/mcpServerForm.vue +++ b/src/renderer/src/components/mcp-config/mcpServerForm.vue @@ -880,7 +880,7 @@ HTTP-Referer=deepchatai.cn` class="h-4 w-4 mr-2" /> {{ - selectedImageModelName || t('settings.common.selectModel') + selectedImageModelName || t('settings.mcp.serverForm.imageModel') }} diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 2191c144d..2fd955495 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -746,7 +746,7 @@ "parseAndContinue": "Fortolk og fortsæt", "jsonParseError": "JSON-fortolkning mislykkedes", "browseMarketplace": "Gennemse MCP Marketplace", - "imageModel": "Vælg en vision-model", + "imageModel": "Vision-model", "customHeadersParseError": "Fortolkning af brugerdefinerede headers mislykkedes", "customHeaders": "Brugerdefinerede forespørgselsheaders", "clickToEdit": "Klik for at redigere og se hele indholdet", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 0f87b678a..a166e0378 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -800,7 +800,7 @@ "parseAndContinue": "Parse & Continue", "jsonParseError": "JSON parsing failed", "browseMarketplace": "Browse MCP Marketplace", - "imageModel": "Choose a vision model", + "imageModel": "Vision Model", "customHeadersParseError": "Custom Header parsing failed", "customHeaders": "Custom Request Headers", "clickToEdit": "Click to edit and view full content", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 199983480..78331f0fe 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -800,7 +800,7 @@ "parseAndContinue": "تجزیه و ادامه", "jsonParseError": "تجزیه JSON ناموفق بود", "browseMarketplace": "مرور بازار MCP", - "imageModel": "انتخاب مدل بصری", + "imageModel": "مدل بصری", "customHeadersParseError": "تجزیه سربرگ دلخواه ناموفق بود", "customHeaders": "سربرگ درخواست دلخواه", "invalidKeyValueFormat": "قالب سربرگ درخواست نادرست است، لطفاً بررسی کنید که ورودی صحیح باشد.", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 5948d5766..b8777673c 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -792,7 +792,7 @@ "jsonParseError": "Échec de l'analyse JSON", "typeHttp": "Requêtes HTTP en streaming (HTTP)", "browseMarketplace": "Allez sur MCP Market et installez-le en un seul clic", - "imageModel": "Sélectionnez un modèle visuel", + "imageModel": "Modèle visuel", "customHeadersParseError": "L'analyse de l'en-tête personnalisée a échoué", "customHeaders": "En-tête de demande personnalisé", "invalidKeyValueFormat": "Format d'en-tête de demande incorrect, veuillez vérifier si l'entrée est correcte.", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 61b4cda59..a88a768c7 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -800,7 +800,7 @@ "parseAndContinue": "פענח והמשך", "jsonParseError": "פענוח JSON נכשל", "browseMarketplace": "עיין בחנות MCP", - "imageModel": "בחר מודל ראייה", + "imageModel": "מודל ראייה", "customHeadersParseError": "פענוח כותרות מותאמות אישית נכשל", "customHeaders": "כותרות בקשה מותאמות אישית", "clickToEdit": "לחץ לעריכה וצפייה בתוכן המלא", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index e6b2dbc1f..d4fb92832 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -792,7 +792,7 @@ "typeHttp": "ストリーミングHTTPリクエスト(HTTP)", "typeInMemory": "メモリ", "browseMarketplace": "MCPサービス市場を閲覧します", - "imageModel": "視覚モデルを選択します", + "imageModel": "視覚モデル", "customHeadersParseError": "カスタムヘッダーの解析は失敗しました", "customHeaders": "カスタムリクエストヘッダー", "clickToEdit": "クリックして編集し、完全な内容を表示", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index dabfb3837..a04ccec05 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -792,7 +792,7 @@ "jsonParseError": "JSON 파싱이 실패했습니다", "typeHttp": "스트리밍 HTTP 요청 (HTTP)", "browseMarketplace": "MCP 서비스 시장을 탐색하십시오", - "imageModel": "시각적 모델을 선택하십시오", + "imageModel": "시각적 모델", "customHeadersParseError": "사용자 정의 헤더 구문 분석이 실패했습니다", "customHeaders": "사용자 정의 요청 헤더", "invalidKeyValueFormat": "잘못된 요청 헤더 형식, 입력이 올바른지 확인하십시오.", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 9906b6a6e..fcc5e4b4c 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -800,7 +800,7 @@ "parseAndContinue": "Analisar e Continuar", "jsonParseError": "Falha na análise do JSON", "browseMarketplace": "Navegar no Mercado MCP", - "imageModel": "Escolha um modelo de visão", + "imageModel": "Modelo de visão", "customHeadersParseError": "Falha na análise do Cabeçalho Personalizado", "customHeaders": "Cabeçalhos de Solicitação Personalizados", "clickToEdit": "Clique para editar e visualizar o conteúdo completo", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index d5ef323f4..2a95a6f14 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -792,7 +792,7 @@ "jsonParseError": "JSON SAINING не удалось", "typeHttp": "Потоковые http -запросы (http)", "browseMarketplace": "Просмотреть рынок услуг MCP", - "imageModel": "Выберите визуальную модель", + "imageModel": "Визуальная модель", "customHeadersParseError": "Пользовательский диапазон заголовка не удалось", "customHeaders": "Пользовательский заголовок запроса", "invalidKeyValueFormat": "Неправильный формат заголовка запроса, пожалуйста, проверьте, является ли вход правильным.", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 54d9fb1f5..c55a4e151 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -799,7 +799,7 @@ "parseAndContinue": "解析并继续", "jsonParseError": "JSON解析失败", "browseMarketplace": "浏览MCP服务市场", - "imageModel": "选择视觉模型", + "imageModel": "视觉模型", "customHeadersParseError": "自定义Header解析失败", "customHeaders": "自定义请求头", "clickToEdit": "点击编辑以查看完整内容", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index b9b89d32d..c17a018a2 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -792,7 +792,7 @@ "jsonParseError": "JSON解析失敗", "typeHttp": "可流式傳輸的HTTP請求(HTTP)", "browseMarketplace": "瀏覽MCP服務市場", - "imageModel": "選擇視覺模型", + "imageModel": "視覺模型", "customHeadersParseError": "自定義Header解析失敗", "customHeaders": "自定義請求頭", "invalidKeyValueFormat": "錯誤的請求頭格式,請檢查輸入是否正確", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index c3d718226..ec4f004c8 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -792,7 +792,7 @@ "typeHttp": "可流式傳輸的HTTP請求(HTTP)", "typeInMemory": "內存(InMemory)", "browseMarketplace": "瀏覽MCP服務市場", - "imageModel": "選擇視覺模型", + "imageModel": "視覺模型", "customHeadersParseError": "自定義Header解析失敗", "customHeaders": "自定義請求頭", "invalidKeyValueFormat": "錯誤的請求頭格式,請檢查輸入是否正確",