From 7f34202661b266f5be680f538aa16e0cac1cb275 Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 22 Aug 2026 17:14:49 +0800 Subject: [PATCH 1/7] feat(conversation): add Auto planner/worker foundation for aionrs Closes #4143 Co-authored-by: Cursor --- docs/prds/conversations/auto-model.md | 86 ++++++++++++++ .../desktop/src/common/config/configKeys.ts | 14 +++ packages/desktop/src/common/config/storage.ts | 27 +++++ .../contents/ModelModalContent.tsx | 88 ++++++++++++-- .../hooks/agent/useAutoModelSettings.ts | 67 +++++++++++ .../components/ChatConversation.tsx | 25 +++- .../platforms/aionrs/AionrsModelSelector.tsx | 34 +++++- .../platforms/aionrs/AionrsSendBox.tsx | 39 +++++-- .../aionrs/useAionrsModelSelection.ts | 46 +++++++- .../src/renderer/pages/guid/GuidPage.tsx | 5 + .../pages/guid/components/GuidActionRow.tsx | 41 +++++-- .../guid/components/GuidModelSelector.tsx | 41 ++++++- .../pages/guid/hooks/useGuidModelSelection.ts | 35 +++++- .../renderer/pages/guid/hooks/useGuidSend.ts | 15 +++ .../src/renderer/services/i18n/i18n-keys.d.ts | 15 +++ .../i18n/locales/de-DE/conversation.json | 6 + .../services/i18n/locales/de-DE/settings.json | 17 ++- .../i18n/locales/en-US/conversation.json | 6 + .../services/i18n/locales/en-US/settings.json | 17 ++- .../i18n/locales/es-ES/conversation.json | 6 + .../services/i18n/locales/es-ES/settings.json | 17 ++- .../i18n/locales/fa-IR/conversation.json | 6 + .../services/i18n/locales/fa-IR/settings.json | 17 ++- .../i18n/locales/fr-FR/conversation.json | 6 + .../services/i18n/locales/fr-FR/settings.json | 17 ++- .../i18n/locales/ja-JP/conversation.json | 6 + .../services/i18n/locales/ja-JP/settings.json | 17 ++- .../i18n/locales/ko-KR/conversation.json | 6 + .../services/i18n/locales/ko-KR/settings.json | 17 ++- .../i18n/locales/pt-BR/conversation.json | 6 + .../services/i18n/locales/pt-BR/settings.json | 17 ++- .../i18n/locales/ru-RU/conversation.json | 6 + .../services/i18n/locales/ru-RU/settings.json | 17 ++- .../i18n/locales/tr-TR/conversation.json | 6 + .../services/i18n/locales/tr-TR/settings.json | 17 ++- .../i18n/locales/uk-UA/conversation.json | 6 + .../services/i18n/locales/uk-UA/settings.json | 17 ++- .../i18n/locales/zh-CN/conversation.json | 6 + .../services/i18n/locales/zh-CN/settings.json | 17 ++- .../i18n/locales/zh-TW/conversation.json | 6 + .../services/i18n/locales/zh-TW/settings.json | 17 ++- .../src/renderer/utils/autoModel/constants.ts | 27 +++++ .../src/renderer/utils/autoModel/index.ts | 18 +++ .../utils/autoModel/resolveAutoModel.ts | 107 ++++++++++++++++++ .../src/renderer/utils/autoModel/settings.ts | 51 +++++++++ .../utils/autoModel/tierHeuristics.ts | 62 ++++++++++ .../src/renderer/utils/autoModel/types.ts | 57 ++++++++++ .../autoModel/resolveAutoModel.test.ts | 87 ++++++++++++++ 48 files changed, 1229 insertions(+), 57 deletions(-) create mode 100644 docs/prds/conversations/auto-model.md create mode 100644 packages/desktop/src/renderer/hooks/agent/useAutoModelSettings.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/constants.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/index.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/settings.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/types.ts create mode 100644 tests/unit/renderer/autoModel/resolveAutoModel.test.ts diff --git a/docs/prds/conversations/auto-model.md b/docs/prds/conversations/auto-model.md new file mode 100644 index 00000000000..17b79c69095 --- /dev/null +++ b/docs/prds/conversations/auto-model.md @@ -0,0 +1,86 @@ +# Auto planner/worker model routing(aionrs) + +> Issue: [#4143](https://github.com/iOfficeAI/AionUi/issues/4143) +> 范围:aionrs 会话模型选择、模型设置页槽位绑定、会话 `extra.auto_model` +> 非目标:ACP 平台、助手 `mode: auto`(「记住上次」)、把 Auto 设成默认模型 + +--- + +## 背景与目标 + +长时间运行(如 7×24)的 aionrs agent 若全程使用高价模型,会在低价值回合上浪费成本。业界常见做法是 **Planner / Worker** 分槽:规划用强模型,执行磨信用便宜模型。 + +本 PRD 分两阶段: + +| 阶段 | 能力 | 用户可感知收益 | +| --- | --- | --- | +| **Phase 1(本 PR)** | UI + 配置 + sticky Auto;选 Auto 时用 **worker** 槽解析出具体 `conversation.model`,并写 `extra.auto_model` | 可配置槽位;会话标记为 Auto;为 Phase 2 打基础 | +| **Phase 2** | Core 支持不重建 agent 切换模型;按 phase 在 planner/worker/utility 间切换 | 真正降低长跑成本 | + +**名词**: + +- **Auto**:产品级「自动路由」入口,与助手权限模式 `mode: auto`(Remember last used)无关。 +- **槽位**:`planner` / `worker` / `utility`,可固定绑定某模型,或设为 Automatic(按偏好打分)。 +- **Sentinel**:选择器内部 id `__aionui_auto__::auto`,**永不**作为后端 model 上报。 + +--- + +## (F-AUTO-01) 选择器出现 Auto 入口 [新增] + +**用户故事**:作为 aionrs 用户,我希望在模型列表顶部看到 Auto,而不是只能手选单个模型。 + +**正常流程**: + +1. 打开 aionrs 对话或 Guid 新建页的模型选择器。 +2. 列表顶部有 **Auto** 分组 / 选项。 +3. 选中后 pill 显示 `Auto · {具体模型名}`。 + +**验收**: + +- [ ] 桌面下拉与移动 action sheet 均有 Auto。 +- [ ] 选中后 `conversation.model` 为具体 provider+model,不是 sentinel。 +- [ ] `extra.auto_model.enabled === true`,且含 `last_resolved`。 + +--- + +## (F-AUTO-02) 设置页绑定槽位与偏好 [新增] + +**用户故事**:作为用户,我希望在模型设置里为 planner/worker/utility 指定模型或 Automatic,并选择成本/质量偏好。 + +**验收**: + +- [ ] 设置写入 `configService` 键 `autoModel`。 +- [ ] Automatic 时按 preference 对可用模型打分选槽;固定绑定时校验模型仍存在,否则回落 Automatic。 +- [ ] 文案说明 Phase 1 实际使用 worker 槽。 + +--- + +## (F-AUTO-03) 手动选模型退出 Auto [不变语义] + +**验收**: + +- [ ] 用户再选具体模型后 `auto_model.enabled` 为 false;pill 不再显示 Auto 前缀。 + +--- + +## (F-AUTO-04) 无可用模型时失败提示 [新增] + +**验收**: + +- [ ] resolve 失败时 Message 提示,不写入无效 model。 + +--- + +## Phase 2(后续,不在本 PR) + +- Core:同一 agent 实例上切换 model,不 stop/rebuild。 +- 按任务 phase 在 planner ↔ worker(及 utility)间切换,并更新 `extra.auto_model.phase` / `last_resolved`。 +- 可选:turn 级启发式路由(更接近 Cursor / OpenSquilla)。 + +--- + +## 实现备注 + +- 解析逻辑:`packages/desktop/src/renderer/utils/autoModel/` +- 会话标记:`conversation.extra.auto_model` +- 勿把 Auto 做成全局默认模型;用户显式选择才启用。 diff --git a/packages/desktop/src/common/config/configKeys.ts b/packages/desktop/src/common/config/configKeys.ts index 96dd25b31af..de7208b9358 100644 --- a/packages/desktop/src/common/config/configKeys.ts +++ b/packages/desktop/src/common/config/configKeys.ts @@ -35,6 +35,20 @@ export type ConfigKeyMap = { 'pet.size': number | undefined; 'pet.dnd': boolean | undefined; 'pet.confirmEnabled': boolean | undefined; + /** + * Auto planner/worker routing preferences for long-running aionrs agents. + * See docs/prds/conversations/auto-model.md and issue #4143. + */ + autoModel: + | { + preference: 'cost' | 'balance' | 'quality'; + slots: { + planner: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + worker: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + utility: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + }; + } + | undefined; // Removed: 'system.autoPreviewOfficeFiles'. It gated "auto-open a preview tab // when an Office file appears in the workspace", a behaviour that was dropped // along with its hook — leaving the toggle would have been a switch the user diff --git a/packages/desktop/src/common/config/storage.ts b/packages/desktop/src/common/config/storage.ts index 3d45137d0ab..40ebc5e2f6f 100644 --- a/packages/desktop/src/common/config/storage.ts +++ b/packages/desktop/src/common/config/storage.ts @@ -86,6 +86,18 @@ export interface IConfigStorageRefer { // Desktop Pet: whether tool-call confirmations are routed to the pet's bubble // (true) or remain in the main chat window (false). Default true. 'pet.confirmEnabled'?: boolean; + /** + * Auto planner/worker routing preferences (long-running aionrs agents). + * Mirrored in ConfigKeyMap.autoModel for configService. + */ + autoModel?: { + preference: 'cost' | 'balance' | 'quality'; + slots: { + planner: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + worker: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + utility: { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; + }; + }; } /** @@ -483,6 +495,21 @@ export type TChatConversation = last_token_usage?: TokenUsageData; /** Cron job ID that spawned this conversation */ cron_job_id?: string; + /** + * Sticky Auto routing flag for this conversation (issue #4143). + * Concrete model always lives on conversation.model; this only marks Auto. + * Phase 2 will use `phase` for planner/worker switches without rebuild. + */ + auto_model?: { + enabled: boolean; + preference?: 'cost' | 'balance' | 'quality'; + phase?: 'planner' | 'worker' | 'utility'; + last_resolved?: { + provider_id: string; + model: string; + slot: 'planner' | 'worker' | 'utility'; + }; + }; } >; diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx index 1bfb814a769..d2ba7691932 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx @@ -7,7 +7,7 @@ import { ipcBridge } from '@/common'; import type { IProvider } from '@/common/config/storage'; import { supportsOpenAiApiMode } from '@/common/utils/modelCapabilities'; -import { Button, Divider, Message, Popconfirm, Collapse, Tag, Switch, Tooltip } from '@arco-design/web-react'; +import { Button, Divider, Message, Popconfirm, Collapse, Tag, Switch, Tooltip, Select, Typography } from '@arco-design/web-react'; import { DeleteFour, Heartbeat, @@ -19,7 +19,7 @@ import { SettingTwo, Write, } from '@icon-park/react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '@/renderer/services/i18n/format'; import AddModelModal from '@/renderer/pages/settings/components/AddModelModal'; @@ -28,7 +28,9 @@ import { isNewApiPlatform, NEW_API_PROTOCOL_OPTIONS } from '@/renderer/utils/mod import EditModeModal from '@/renderer/pages/settings/components/EditModeModal'; import AionScrollArea from '@/renderer/components/base/AionScrollArea'; import TalkToButlerButton from '@/renderer/components/base/TalkToButlerButton'; -import { useProvidersQuery } from '@/renderer/hooks/agent/useModelProviderList'; +import { useModelProviderList, useProvidersQuery } from '@/renderer/hooks/agent/useModelProviderList'; +import { listSlotModelOptions, useAutoModelSettings } from '@/renderer/hooks/agent/useAutoModelSettings'; +import type { AutoModelSlotBinding, AutoModelSlots } from '@/renderer/utils/autoModel'; import { useSettingsViewMode } from '../settingsViewContext'; import SettingsPageHeader from '@/renderer/pages/settings/components/SettingsPageHeader'; import { consumePendingDeepLink } from '@/renderer/hooks/system/useDeepLink'; @@ -106,6 +108,73 @@ const isModelEnabled = (platform: IProvider, model: string): boolean => { return platform.model_enabled[model] !== false; }; +const AUTO_SLOT_KEYS: Array = ['planner', 'worker', 'utility']; + +const bindingToSelectValue = (binding: AutoModelSlotBinding): string => { + if (binding.mode === 'automatic') return 'automatic'; + return `${binding.provider_id}::${binding.model}`; +}; + +const parseSelectValue = (value: string): AutoModelSlotBinding => { + if (value === 'automatic') return { mode: 'automatic' }; + const [provider_id, ...rest] = value.split('::'); + return { mode: 'fixed', provider_id, model: rest.join('::') }; +}; + +/** Phase-1 Auto planner/worker slot bindings (issue #4143). */ +const AutoModelSettingsBlock: React.FC = () => { + const { t } = useTranslation(); + const { settings, setPreference, setSlot } = useAutoModelSettings(); + const { providers, getAvailableModels } = useModelProviderList(); + const modelOptions = useMemo( + () => listSlotModelOptions(providers, getAvailableModels), + [getAvailableModels, providers] + ); + + return ( +
+ + {t('settings.autoModel.title')} + + + {t('settings.autoModel.description')} + +
+
{t('settings.autoModel.preference')}
+ void setSlot(slot, parseSelectValue(String(value)))} + showSearch + options={[ + { label: t('settings.autoModel.automatic'), value: 'automatic' }, + ...modelOptions.map((option) => ({ + label: `${option.provider.name} / ${option.modelName}`, + value: option.value, + })), + ]} + /> +
+ ))} + + {t('settings.autoModel.phaseNote')} + +
+ ); +}; + const ModelModalContent: React.FC = () => { const { t, i18n } = useTranslation(); const viewMode = useSettingsViewMode(); @@ -402,7 +471,9 @@ const ModelModalContent: React.FC = () => { {/* Content Area */} - {!data || data.length === 0 ? ( +
+ + {!data || data.length === 0 ? (

{t('settings.noConfiguredModels')}

@@ -420,8 +491,7 @@ const ModelModalContent: React.FC = () => {

) : ( -
- {(data || []).map((platform: IProvider) => { + (data || []).map((platform: IProvider) => { const key = platform.id; const isExpanded = collapseKey[platform.id] ?? false; return ( @@ -682,9 +752,9 @@ const ModelModalContent: React.FC = () => { ); - })} -
- )} + }) + )} +
); diff --git a/packages/desktop/src/renderer/hooks/agent/useAutoModelSettings.ts b/packages/desktop/src/renderer/hooks/agent/useAutoModelSettings.ts new file mode 100644 index 00000000000..726c8e2e6f1 --- /dev/null +++ b/packages/desktop/src/renderer/hooks/agent/useAutoModelSettings.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IProvider } from '@/common/config/storage'; +import { useConfig } from '@/renderer/hooks/config/useConfig'; +import { + AUTO_MODEL_CONFIG_KEY, + defaultAutoModelSettings, + normalizeAutoModelSettings, + type AutoModelPreference, + type AutoModelSettings, + type AutoModelSlotBinding, + type AutoModelSlots, +} from '@/renderer/utils/autoModel'; +import { useCallback, useMemo } from 'react'; + +export const useAutoModelSettings = () => { + const [raw, setRaw] = useConfig(AUTO_MODEL_CONFIG_KEY); + const settings = useMemo(() => normalizeAutoModelSettings(raw ?? defaultAutoModelSettings()), [raw]); + + const setSettings = useCallback( + async (next: AutoModelSettings) => { + await setRaw(normalizeAutoModelSettings(next)); + }, + [setRaw] + ); + + const setPreference = useCallback( + async (preference: AutoModelPreference) => { + await setSettings({ ...settings, preference }); + }, + [setSettings, settings] + ); + + const setSlot = useCallback( + async (slot: keyof AutoModelSlots, binding: AutoModelSlotBinding) => { + await setSettings({ + ...settings, + slots: { ...settings.slots, [slot]: binding }, + }); + }, + [setSettings, settings] + ); + + return { settings, setSettings, setPreference, setSlot }; +}; + +export const listSlotModelOptions = ( + providers: IProvider[], + getAvailableModels: (provider: IProvider) => string[] +): Array<{ provider: IProvider; modelName: string; value: string }> => { + const options: Array<{ provider: IProvider; modelName: string; value: string }> = []; + for (const provider of providers) { + if (provider.enabled === false) continue; + for (const modelName of getAvailableModels(provider)) { + options.push({ + provider, + modelName, + value: `${provider.id}::${modelName}`, + }); + } + } + return options; +}; diff --git a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx index 1b597cc51f2..3eaa2287ee8 100644 --- a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx +++ b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx @@ -152,7 +152,7 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid }) => { const runtimeView = useConversationRuntimeView(conversation.id); const onSelectModel = useCallback( - async (_provider: IProvider, modelName: string) => { + async (_provider: IProvider, modelName: string, meta?: { autoEnabled: boolean }) => { const selected = { ..._provider, use_model: modelName } as TProviderWithModel; // Kill running agent on model switch — will be rebuilt with new model on next message if (runtimeView.activeTurnId) { @@ -162,7 +162,27 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid }); runtimeView.markStopAcknowledged(runtimeView.activeTurnId, result.runtime); } - const ok = await ipcBridge.conversation.update.invoke({ id: conversation.id, updates: { model: selected } }); + const autoEnabled = Boolean(meta?.autoEnabled); + const ok = await ipcBridge.conversation.update.invoke({ + id: conversation.id, + updates: { + model: selected, + extra: { + auto_model: autoEnabled + ? { + enabled: true, + phase: 'worker', + last_resolved: { + provider_id: selected.id, + model: selected.use_model, + slot: 'worker', + }, + } + : { enabled: false }, + }, + }, + merge_extra: true, + }); return Boolean(ok); }, [conversation.id, runtimeView] @@ -170,6 +190,7 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid const modelSelection = useAionrsModelSelection({ initialModel: conversation.model, + initialAutoEnabled: Boolean(conversation.extra?.auto_model?.enabled), onSelectModel, }); // Project conversations get the Layout-level Explorer column (stage3 FULL); diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx index b688ee6234b..f3c74773bc7 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx @@ -69,11 +69,16 @@ const AionrsModelSelector: React.FC<{ ); } - const { providers, getAvailableModels, handleSelectModel } = selection; + const { providers, getAvailableModels, handleSelectModel, handleSelectAuto, autoEnabled } = selection; const label = getModelDisplayLabel({ selected_value: current_model?.use_model, - selectedLabel: current_model?.use_model || '', + selectedLabel: autoEnabled + ? t('conversation.autoModel.pill', { + model: current_model?.use_model || '', + defaultValue: `Auto · ${current_model?.use_model || ''}`, + }) + : current_model?.use_model || '', defaultModelLabel, fallbackLabel: t('conversation.welcome.selectModel'), }); @@ -85,7 +90,20 @@ const AionrsModelSelector: React.FC<{ // aionrs models are grouped by provider. Use a composite id (see compositeId) // so the shared model list can track selection, and map it back on select. - const modelGroups: RuntimeSelectorModelGroup[] = []; + const modelGroups: RuntimeSelectorModelGroup[] = [ + { + key: 'aionui-auto', + title: t('conversation.autoModel.groupTitle', { defaultValue: 'Auto' }), + models: [ + { + id: '__aionui_auto__::auto', + label: t('conversation.autoModel.optionLabel', { + defaultValue: 'Auto (planner/worker routing)', + }), + }, + ], + }, + ]; const modelLookup = new Map(); for (const provider of providers) { const models = getAvailableModels(provider); @@ -100,8 +118,16 @@ const AionrsModelSelector: React.FC<{ }), }); } - const currentCompositeId = current_model ? compositeId(current_model.id, current_model.use_model || '') : null; + const currentCompositeId = autoEnabled + ? '__aionui_auto__::auto' + : current_model + ? compositeId(current_model.id, current_model.use_model || '') + : null; const handleModelSelect = (id: string) => { + if (id === '__aionui_auto__::auto') { + void handleSelectAuto(); + return; + } const entry = modelLookup.get(id); if (entry) void handleSelectModel(entry.provider, entry.modelName); }; diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx index bdf6c8aec45..dac868bfc13 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx @@ -503,6 +503,10 @@ const AionrsSendBox: React.FC<{ const handleSheetModelSelect = useCallback( (value: string) => { if (runtimeConfig.isConfigOptionBlocked?.('model')) return; + if (value === '__aionui_auto__::auto') { + void modelSelection.handleSelectAuto(); + return; + } // value format: `${providerId}::${modelName}` const [providerId, modelName] = value.split('::'); const provider = modelSelection.providers.find((p) => p.id === providerId); @@ -535,19 +539,34 @@ const AionrsSendBox: React.FC<{ active: (runtimeMode?.currentValue ?? currentMode) === mode.value, })); - const modelOptions: MobileActionSheetOption[] = modelSelection.providers.flatMap((provider) => - modelSelection.getAvailableModels(provider).map((modelName) => ({ - key: `${provider.id}::${modelName}`, - label: modelName, - description: provider.name, - active: - modelSelection.current_model?.id === provider.id && modelSelection.current_model?.use_model === modelName, - })) - ); + const modelOptions: MobileActionSheetOption[] = [ + { + key: '__aionui_auto__::auto', + label: t('conversation.autoModel.optionLabel', { defaultValue: 'Auto (planner/worker routing)' }), + description: t('conversation.autoModel.groupTitle', { defaultValue: 'Auto' }), + active: Boolean(modelSelection.autoEnabled), + }, + ...modelSelection.providers.flatMap((provider) => + modelSelection.getAvailableModels(provider).map((modelName) => ({ + key: `${provider.id}::${modelName}`, + label: modelName, + description: provider.name, + active: + !modelSelection.autoEnabled && + modelSelection.current_model?.id === provider.id && + modelSelection.current_model?.use_model === modelName, + })) + ), + ]; const currentModeLabel = modeOptions.find((opt) => opt.active)?.label ?? t('agentMode.default', { defaultValue: 'Default' }); - const currentModelLabel = modelSelection.current_model?.use_model || t('conversation.welcome.selectModel'); + const currentModelLabel = modelSelection.autoEnabled + ? t('conversation.autoModel.pill', { + model: modelSelection.current_model?.use_model || '', + defaultValue: `Auto · ${modelSelection.current_model?.use_model || ''}`, + }) + : modelSelection.current_model?.use_model || t('conversation.welcome.selectModel'); const entries: MobileActionSheetEntry[] = [ { diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts index a558a30c36c..7caaea54a11 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts @@ -6,31 +6,49 @@ import type { IProvider, TProviderWithModel } from '@/common/config/storage'; import { useModelProviderList } from '@/renderer/hooks/agent/useModelProviderList'; +import { readAutoModelSettings, resolveAutoModel } from '@/renderer/utils/autoModel'; +import { Message } from '@arco-design/web-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; export type AionrsModelSelection = { current_model?: TProviderWithModel; + /** Conversation is in Auto mode (concrete model still on current_model). */ + autoEnabled: boolean; providers: IProvider[]; getAvailableModels: (provider: IProvider) => string[]; handleSelectModel: (provider: IProvider, modelName: string) => Promise; + handleSelectAuto: () => Promise; getDisplayModelName: (modelName?: string) => string; }; export type UseAionrsModelSelectionOptions = { initialModel: TProviderWithModel | undefined; - onSelectModel: (provider: IProvider, modelName: string) => Promise; + initialAutoEnabled?: boolean; + onSelectModel: ( + provider: IProvider, + modelName: string, + meta?: { autoEnabled: boolean } + ) => Promise; }; export const useAionrsModelSelection = ({ initialModel, + initialAutoEnabled = false, onSelectModel, }: UseAionrsModelSelectionOptions): AionrsModelSelection => { + const { t } = useTranslation(); const [current_model, setCurrentModel] = useState(initialModel); + const [autoEnabled, setAutoEnabled] = useState(initialAutoEnabled); useEffect(() => { setCurrentModel(initialModel); }, [initialModel?.id, initialModel?.use_model]); + useEffect(() => { + setAutoEnabled(initialAutoEnabled); + }, [initialAutoEnabled]); + const { providers: allProviders, getAvailableModels, formatModelLabel } = useModelProviderList(); // AionCore does not support Google Auth — filter it out @@ -45,14 +63,36 @@ export const useAionrsModelSelection = ({ ...(provider as unknown as TProviderWithModel), use_model: modelName, } as TProviderWithModel; - const ok = await onSelectModel(provider, modelName); + const ok = await onSelectModel(provider, modelName, { autoEnabled: false }); if (ok) { setCurrentModel(selected); + setAutoEnabled(false); } }, [onSelectModel] ); + const handleSelectAuto = useCallback(async () => { + try { + const resolved = resolveAutoModel({ + phase: 'worker', + settings: readAutoModelSettings(), + providers, + getAvailableModels, + }); + const ok = await onSelectModel(resolved.model as unknown as IProvider, resolved.model.use_model, { + autoEnabled: true, + }); + if (ok) { + setCurrentModel(resolved.model); + setAutoEnabled(true); + } + } catch (error) { + console.error('Failed to resolve Auto model:', error); + Message.warning(t('conversation.autoModel.noCandidates', { defaultValue: 'No available models for Auto' })); + } + }, [getAvailableModels, onSelectModel, providers, t]); + const getDisplayModelName = useCallback( (modelName?: string) => { if (!modelName) return ''; @@ -65,9 +105,11 @@ export const useAionrsModelSelection = ({ return { current_model, + autoEnabled, providers, getAvailableModels, handleSelectModel, + handleSelectAuto, getDisplayModelName, }; }; diff --git a/packages/desktop/src/renderer/pages/guid/GuidPage.tsx b/packages/desktop/src/renderer/pages/guid/GuidPage.tsx index 78d80d6c4ab..8c652ec6639 100644 --- a/packages/desktop/src/renderer/pages/guid/GuidPage.tsx +++ b/packages/desktop/src/renderer/pages/guid/GuidPage.tsx @@ -263,6 +263,7 @@ const GuidPage: React.FC = () => { selectedAcpModel: agentSelection.selectedAcpModel, selectedThoughtLevelValue: agentSelection.selectedThoughtLevelValue, current_model: modelSelection.current_model, + autoEnabled: modelSelection.autoEnabled, guidDisabledBuiltinSkills, guidEnabledSkills, @@ -595,6 +596,8 @@ const GuidPage: React.FC = () => { modelList={modelSelection.modelList} current_model={modelSelection.current_model} setCurrentModel={setGuidCurrentModel} + autoEnabled={modelSelection.autoEnabled} + onSelectAuto={modelSelection.selectAutoModel} currentAcpCachedModelInfo={agentSelection.currentAcpCachedModelInfo} selectedAcpModel={agentSelection.selectedAcpModel} setSelectedAcpModel={setGuidSelectedAcpModel} @@ -622,6 +625,8 @@ const GuidPage: React.FC = () => { modelList={modelSelection.modelList} current_model={modelSelection.current_model} setCurrentModel={setGuidCurrentModel} + autoEnabled={modelSelection.autoEnabled} + onSelectAuto={modelSelection.selectAutoModel} currentAcpCachedModelInfo={agentSelection.currentAcpCachedModelInfo} selectedAcpModel={agentSelection.selectedAcpModel} setSelectedAcpModel={setGuidSelectedAcpModel} diff --git a/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx b/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx index 218520f1575..9c919172619 100644 --- a/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx +++ b/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx @@ -87,6 +87,8 @@ type GuidActionRowProps = { modelList: IProvider[]; current_model?: TProviderWithModel; setCurrentModel: (model: TProviderWithModel) => Promise; + autoEnabled?: boolean; + onSelectAuto?: () => Promise; currentAcpCachedModelInfo: AcpModelInfo | null; selectedAcpModel: string | null; setSelectedAcpModel: (model: string | null) => void; @@ -126,6 +128,8 @@ const GuidActionRow: React.FC = ({ modelList, current_model, setCurrentModel, + autoEnabled = false, + onSelectAuto, currentAcpCachedModelInfo, selectedAcpModel, setSelectedAcpModel, @@ -234,16 +238,33 @@ const GuidActionRow: React.FC = ({ let onModelSelect: (key: string) => void = () => {}; if (isGeminiMode) { const enabled = modelList.filter((p) => p.enabled !== false); - modelOptions = enabled.flatMap((provider) => - getAvailableModels(provider).map((modelName) => ({ - key: `${provider.id}::${modelName}`, - label: modelName, - description: provider.name, - active: current_model?.id === provider.id && current_model?.use_model === modelName, - })) - ); - currentModelLabel = current_model?.use_model || ''; + modelOptions = [ + { + key: '__aionui_auto__::auto', + label: t('conversation.autoModel.optionLabel', { defaultValue: 'Auto (planner/worker routing)' }), + description: t('conversation.autoModel.groupTitle', { defaultValue: 'Auto' }), + active: autoEnabled, + }, + ...enabled.flatMap((provider) => + getAvailableModels(provider).map((modelName) => ({ + key: `${provider.id}::${modelName}`, + label: modelName, + description: provider.name, + active: !autoEnabled && current_model?.id === provider.id && current_model?.use_model === modelName, + })) + ), + ]; + currentModelLabel = autoEnabled + ? t('conversation.autoModel.pill', { + model: current_model?.use_model || '', + defaultValue: `Auto · ${current_model?.use_model || ''}`, + }) + : current_model?.use_model || ''; onModelSelect = (key) => { + if (key === '__aionui_auto__::auto') { + void onSelectAuto?.(); + return; + } const [providerId, modelName] = key.split('::'); const provider = enabled.find((p) => p.id === providerId); if (provider) void setCurrentModel({ ...provider, use_model: modelName } as TProviderWithModel); @@ -407,6 +428,8 @@ const GuidActionRow: React.FC = ({ modelList, current_model, setCurrentModel, + autoEnabled, + onSelectAuto, currentAcpCachedModelInfo, selectedAcpModel, setSelectedAcpModel, diff --git a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx index db02327eed6..64e9891368a 100644 --- a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx @@ -31,6 +31,8 @@ type GuidModelSelectorProps = { modelList: IProvider[]; current_model: TProviderWithModel | undefined; setCurrentModel: (model: TProviderWithModel) => Promise; + autoEnabled?: boolean; + onSelectAuto?: () => Promise; // ACP model state currentAcpCachedModelInfo: AcpModelInfo | null; @@ -48,6 +50,8 @@ const GuidModelSelector: React.FC = ({ modelList, current_model, setCurrentModel, + autoEnabled = false, + onSelectAuto, currentAcpCachedModelInfo, selectedAcpModel, setSelectedAcpModel, @@ -65,8 +69,14 @@ const GuidModelSelector: React.FC = ({ const geminiSelectedLabel = React.useMemo(() => { if (!current_model?.use_model) return ''; + if (autoEnabled) { + return t('conversation.autoModel.pill', { + model: current_model.use_model, + defaultValue: `Auto · ${current_model.use_model}`, + }); + } return current_model.use_model; - }, [current_model?.use_model]); + }, [autoEnabled, current_model?.use_model, t]); const geminiButtonLabel = React.useMemo(() => { return getModelDisplayLabel({ @@ -115,7 +125,20 @@ const GuidModelSelector: React.FC = ({ if (isGeminiMode) { // Provider-grouped models (e.g. aionrs). Build groups + a composite-id lookup // so the shared model list can search across providers and map back on select. - const providerModelGroups: RuntimeSelectorModelGroup[] = []; + const providerModelGroups: RuntimeSelectorModelGroup[] = [ + { + key: 'aionui-auto', + title: t('conversation.autoModel.groupTitle', { defaultValue: 'Auto' }), + models: [ + { + id: '__aionui_auto__::auto', + label: t('conversation.autoModel.optionLabel', { + defaultValue: 'Auto (planner/worker routing)', + }), + }, + ], + }, + ]; const providerModelLookup = new Map(); for (const provider of enabledModelList) { const available_models = getAvailableModels(provider); @@ -130,9 +153,11 @@ const GuidModelSelector: React.FC = ({ }), }); } - const currentProviderModelId = current_model - ? providerCompositeId(current_model.id, current_model.use_model || '') - : null; + const currentProviderModelId = autoEnabled + ? '__aionui_auto__::auto' + : current_model + ? providerCompositeId(current_model.id, current_model.use_model || '') + : null; const addModelItem = ( navigate('/settings/model')}> @@ -145,7 +170,7 @@ const GuidModelSelector: React.FC = ({ trigger='hover' droplist={ - {providerModelGroups.length === 0 + {providerModelGroups.length <= 1 ? [ = ({ groups={providerModelGroups} currentModelId={currentProviderModelId} onSelect={(id) => { + if (id === '__aionui_auto__::auto') { + void onSelectAuto?.(); + return; + } const entry = providerModelLookup.get(id); if (!entry) return; setCurrentModel({ ...entry.provider, use_model: entry.modelName } as TProviderWithModel).catch( diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts index 84a73caaf50..b2f17ad92bb 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts @@ -7,8 +7,11 @@ import type { IProvider, TProviderWithModel } from '@/common/config/storage'; import { useGoogleAuthModels } from '@/renderer/hooks/agent/useGoogleAuthModels'; import { useProvidersQuery } from '@/renderer/hooks/agent/useModelProviderList'; -import { hasAvailableModels } from '../utils/modelUtils'; +import { hasAvailableModels, getAvailableModels } from '../utils/modelUtils'; +import { readAutoModelSettings, resolveAutoModel } from '@/renderer/utils/autoModel'; +import { Message } from '@arco-design/web-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; /** * Build a unique key for a provider/model pair. @@ -37,7 +40,9 @@ export type GuidModelSelectionResult = { isGoogleAuth: boolean; formatGeminiModelLabel: (provider: { platform?: string } | undefined, modelName?: string) => string; current_model: TProviderWithModel | undefined; + autoEnabled: boolean; setCurrentModel: (model_info: TProviderWithModel, options?: { persistPreference?: boolean }) => Promise; + selectAutoModel: () => Promise; resetCurrentModel: (options?: { persistPreference?: boolean }) => Promise; }; @@ -48,6 +53,7 @@ export type GuidModelSelectionResult = { * @param agentKey - current provider-based agent (currently only 'aionrs') */ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): GuidModelSelectionResult => { + const { t } = useTranslation(); const { isGoogleAuth } = useGoogleAuthModels(); const { data: modelConfig } = useProvidersQuery(); @@ -62,16 +68,35 @@ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): Gu }, []); const [current_model, _setCurrentModel] = useState(); + const [autoEnabled, setAutoEnabled] = useState(false); const selectedModelKeyRef = useRef(null); const setCurrentModel = useCallback( async (model_info: TProviderWithModel, _options?: { persistPreference?: boolean }) => { selectedModelKeyRef.current = buildModelKey(model_info.id, model_info.use_model); _setCurrentModel(model_info); + setAutoEnabled(false); }, [] ); + const selectAutoModel = useCallback(async () => { + try { + const resolved = resolveAutoModel({ + phase: 'worker', + settings: readAutoModelSettings(), + providers: modelList, + getAvailableModels, + }); + selectedModelKeyRef.current = buildModelKey(resolved.model.id, resolved.model.use_model); + _setCurrentModel(resolved.model); + setAutoEnabled(true); + } catch (error) { + console.error('Failed to resolve Auto model:', error); + Message.warning(t('conversation.autoModel.noCandidates', { defaultValue: 'No available models for Auto' })); + } + }, [modelList, t]); + const resetCurrentModel = useCallback( async (options?: { persistPreference?: boolean }) => { if (!modelList || modelList.length === 0) { @@ -79,6 +104,7 @@ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): Gu } selectedModelKeyRef.current = null; + setAutoEnabled(false); const defaultModel = modelList[0]; const resolvedUseModel = defaultModel?.models[0] ?? ''; @@ -102,6 +128,9 @@ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): Gu if (!modelList || modelList.length === 0) { return; } + if (autoEnabled) { + return; + } const currentKey = selectedModelKeyRef.current || buildModelKey(current_model?.id, current_model?.use_model); if (isModelKeyAvailable(currentKey, modelList)) { if (!selectedModelKeyRef.current && currentKey) { @@ -115,13 +144,15 @@ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): Gu setDefaultModel().catch((error) => { console.error('Failed to set default model:', error); }); - }, [agentKey, current_model?.id, current_model?.use_model, modelList, resetCurrentModel]); + }, [agentKey, autoEnabled, current_model?.id, current_model?.use_model, modelList, resetCurrentModel]); return { modelList, isGoogleAuth, formatGeminiModelLabel, current_model, + autoEnabled, setCurrentModel, + selectAutoModel, resetCurrentModel, }; }; diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts index 710e988851a..2e6362e99db 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts @@ -35,6 +35,8 @@ export type GuidSendDeps = { selectedAcpModel: string | null; selectedThoughtLevelValue?: string; current_model: TProviderWithModel | undefined; + /** Sticky Auto selection for aionrs (issue #4143). */ + autoEnabled?: boolean; guidDisabledBuiltinSkills: string[] | undefined; guidEnabledSkills: string[] | undefined; @@ -82,6 +84,7 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { selectedAcpModel, selectedThoughtLevelValue, current_model, + autoEnabled = false, guidDisabledBuiltinSkills, guidEnabledSkills, assistantDefaultSkillIds, @@ -190,6 +193,17 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { custom_workspace: isCustomWorkspace, selected_mcp_server_ids: selectedUserMcpServerIdsToSend, selected_session_mcp_servers: selectedSessionMcpServersToSend, + auto_model: autoEnabled + ? { + enabled: true, + phase: 'worker', + last_resolved: { + provider_id: current_model.id, + model: current_model.use_model, + slot: 'worker', + }, + } + : { enabled: false }, }, }); @@ -291,6 +305,7 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { selectedAcpModel, selectedThoughtLevelValue, current_model, + autoEnabled, guidDisabledBuiltinSkills, guidEnabledSkills, assistantDefaultSkillIds, diff --git a/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts b/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts index 6a3249b839a..528ea264678 100644 --- a/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts +++ b/packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts @@ -488,6 +488,10 @@ export type I18nKey = | 'conversation.agentTip.codes.CLI_VERSION_NEWER.body' | 'conversation.agentTip.codes.CLI_VERSION_OLDER.body' | 'conversation.agentTip.codes.CODEX_RETRYING.body' + | 'conversation.autoModel.groupTitle' + | 'conversation.autoModel.noCandidates' + | 'conversation.autoModel.optionLabel' + | 'conversation.autoModel.pill' | 'conversation.chat.apiErrorNoCli' | 'conversation.chat.apiErrorSwitchFailed' | 'conversation.chat.apiErrorSwitched' @@ -1752,6 +1756,17 @@ export type I18nKey = | 'settings.autoInjectedSkills' | 'settings.autoInjectedSkillsBadge' | 'settings.autoInjectedSkillsHint' + | 'settings.autoModel.automatic' + | 'settings.autoModel.description' + | 'settings.autoModel.phaseNote' + | 'settings.autoModel.preference' + | 'settings.autoModel.preferenceBalance' + | 'settings.autoModel.preferenceCost' + | 'settings.autoModel.preferenceQuality' + | 'settings.autoModel.slot.planner' + | 'settings.autoModel.slot.utility' + | 'settings.autoModel.slot.worker' + | 'settings.autoModel.title' | 'settings.baseUrl' | 'settings.baseUrlAutoFix' | 'settings.baseUrlHint' diff --git a/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json index d1f71d9b40d..2c72de7c103 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json @@ -782,5 +782,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Plan ausklappen", "collapse": "Plan einklappen" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (Planner/Worker-Routing)", + "pill": "Auto · {{model}}", + "noCandidates": "Keine verfügbaren Modelle für Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json b/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json index 57e44fa05f6..5fb39ca7d67 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "Erlaubt Agenten, Nachrichten an Ihre anderen Unterhaltungen zu senden. Beim Ausschalten wird auch die @@-Erwähnung im Eingabefeld deaktiviert.", "crossSessionMessageDisabledBanner": "Nachrichten zwischen Unterhaltungen sind aus, die @@-Erwähnung ist nicht verfügbar.", "crossSessionMessageResume": "Wieder einschalten", - "crossSessionMessageUpdateFailed": "Einstellung konnte nicht aktualisiert werden" + "crossSessionMessageUpdateFailed": "Einstellung konnte nicht aktualisiert werden", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json index 27a1d73c482..c637eb0f615 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expand plan", "collapse": "Collapse plan" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (planner/worker routing)", + "pill": "Auto · {{model}}", + "noCandidates": "No available models for Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json index f04bd0a7bb9..d2aa2dadda3 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "Let agents send messages to your other conversations. Turning this off also disables the @@ conversation mention in the input box.", "crossSessionMessageDisabledBanner": "Cross-conversation messages are off, so the @@ conversation mention is unavailable.", "crossSessionMessageResume": "Turn back on", - "crossSessionMessageUpdateFailed": "Failed to update the cross-conversation message setting" + "crossSessionMessageUpdateFailed": "Failed to update the cross-conversation message setting", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json index b6e95b39492..5ac47a39206 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json @@ -782,5 +782,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expandir plan", "collapse": "Contraer plan" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (enrutamiento planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "No hay modelos disponibles para Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json b/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json index 93c61eef66a..5ef45f22398 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "Permite que los agentes envíen mensajes a tus otras conversaciones. Al desactivarlo también se desactiva la mención @@ en el cuadro de texto.", "crossSessionMessageDisabledBanner": "Los mensajes entre conversaciones están desactivados, la mención @@ no está disponible.", "crossSessionMessageResume": "Volver a activar", - "crossSessionMessageUpdateFailed": "No se pudo actualizar el ajuste de mensajes entre conversaciones" + "crossSessionMessageUpdateFailed": "No se pudo actualizar el ajuste de mensajes entre conversaciones", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json index 36b62dffc72..6b288c986c5 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "گسترش برنامه", "collapse": "جمع کردن برنامه" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (مسیریابی planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "مدلی برای Auto در دسترس نیست" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json index 902c4c34374..a5444051374 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "به عامل‌ها اجازه می‌دهد به گفتگوهای دیگر شما پیام بفرستند. با خاموش کردن، منشن @@ در کادر ورودی نیز غیرفعال می‌شود.", "crossSessionMessageDisabledBanner": "پیام‌های بین گفتگوها خاموش است، منشن @@ در دسترس نیست.", "crossSessionMessageResume": "روشن کردن دوباره", - "crossSessionMessageUpdateFailed": "به‌روزرسانی تنظیم پیام‌های بین گفتگوها ناموفق بود" + "crossSessionMessageUpdateFailed": "به‌روزرسانی تنظیم پیام‌های بین گفتگوها ناموفق بود", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json index 296ac6354a1..3591dd0a5c0 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Développer le plan", "collapse": "Réduire le plan" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (routage planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "Aucun modèle disponible pour Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json index eb189152e60..bda2b25a756 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "Permet aux agents d'envoyer des messages à vos autres conversations. La désactivation coupe aussi la mention @@ dans la zone de saisie.", "crossSessionMessageDisabledBanner": "Les messages entre conversations sont désactivés, la mention @@ est indisponible.", "crossSessionMessageResume": "Réactiver", - "crossSessionMessageUpdateFailed": "Échec de la mise à jour du réglage des messages entre conversations" + "crossSessionMessageUpdateFailed": "Échec de la mise à jour du réglage des messages entre conversations", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json index ff0e2f1ea4a..9034e3fafaa 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "プランを展開", "collapse": "プランを折りたたむ" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(プランナー/ワーカー振り分け)", + "pill": "Auto · {{model}}", + "noCandidates": "Auto に使えるモデルがありません" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json index b3ecac6f67e..e410842c852 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json @@ -1414,5 +1414,20 @@ "crossSessionMessageDesc": "エージェントが他の会話にメッセージを送れるようにします。オフにすると入力欄の @@ 会話メンションも無効になります。", "crossSessionMessageDisabledBanner": "会話間メッセージはオフです。入力欄の @@ 会話メンションは使用できません。", "crossSessionMessageResume": "再度オンにする", - "crossSessionMessageUpdateFailed": "会話間メッセージ設定の更新に失敗しました" + "crossSessionMessageUpdateFailed": "会話間メッセージ設定の更新に失敗しました", + "autoModel": { + "title": "Auto モデルルーティング", + "description": "長時間稼働する aionrs エージェント向けに planner / worker / utility スロットを設定します。Phase 1 では Auto 選択時に具体モデルへ解決し、Phase 2 でエージェント再構築なしのスロット切替を予定しています。", + "preference": "スロットが自動のとき", + "preferenceCost": "コスト優先", + "preferenceBalance": "コストと品質のバランス", + "preferenceQuality": "品質優先", + "automatic": "自動", + "slot": { + "planner": "Planner モデル", + "worker": "Worker モデル", + "utility": "Utility モデル" + }, + "phaseNote": "現在 Auto は worker スロットを使用します。planner/utility 切替は、Core がエージェント停止なしでモデル変更できるようになってから実装されます。" + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json index ef5b3358240..5b74fdc1756 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "계획 펼치기", "collapse": "계획 접기" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(플래너/워커 라우팅)", + "pill": "Auto · {{model}}", + "noCandidates": "Auto에 사용할 수 있는 모델이 없습니다" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json index 94d4f9d4639..d413802743b 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json @@ -1414,5 +1414,20 @@ "crossSessionMessageDesc": "에이전트가 다른 대화로 메시지를 보낼 수 있게 합니다. 끄면 입력창의 @@ 대화 멘션도 비활성화됩니다.", "crossSessionMessageDisabledBanner": "대화 간 메시지가 꺼져 있어 입력창의 @@ 대화 멘션을 사용할 수 없습니다.", "crossSessionMessageResume": "다시 켜기", - "crossSessionMessageUpdateFailed": "대화 간 메시지 설정 업데이트에 실패했습니다" + "crossSessionMessageUpdateFailed": "대화 간 메시지 설정 업데이트에 실패했습니다", + "autoModel": { + "title": "Auto 모델 라우팅", + "description": "장시간 실행되는 aionrs 에이전트를 위해 planner, worker, utility 슬롯을 연결합니다. 1단계에서는 Auto 선택 시 구체 모델로 해석하고, 2단계에서는 에이전트를 재구성하지 않고 슬롯을 전환할 예정입니다.", + "preference": "슬롯이 자동일 때", + "preferenceCost": "비용 우선", + "preferenceBalance": "비용과 품질 균형", + "preferenceQuality": "품질 우선", + "automatic": "자동", + "slot": { + "planner": "Planner 모델", + "worker": "Worker 모델", + "utility": "Utility 모델" + }, + "phaseNote": "현재 Auto는 worker 슬롯을 사용합니다. planner/utility 전환은 Core가 에이전트를 중지하지 않고 모델을 바꿀 수 있을 때 제공됩니다." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json index 0df5598bdf8..28f01df3022 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json @@ -787,5 +787,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expandir plano", "collapse": "Recolher plano" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (roteamento planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "Nenhum modelo disponível para Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json index 00147c20f9a..28366e82d66 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json @@ -1415,5 +1415,20 @@ "crossSessionMessageDesc": "Permite que agentes enviem mensagens para suas outras conversas. Desativar também desliga a menção @@ na caixa de entrada.", "crossSessionMessageDisabledBanner": "As mensagens entre conversas estão desativadas, a menção @@ não está disponível.", "crossSessionMessageResume": "Ativar novamente", - "crossSessionMessageUpdateFailed": "Falha ao atualizar a configuração de mensagens entre conversas" + "crossSessionMessageUpdateFailed": "Falha ao atualizar a configuração de mensagens entre conversas", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json index 08098f7a6dc..280d9c857bd 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json @@ -797,5 +797,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Развернуть план", "collapse": "Свернуть план" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (маршрутизация planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "Нет доступных моделей для Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json index c518eeecf3c..108f3a62b8a 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json @@ -1426,5 +1426,20 @@ "crossSessionMessageDesc": "Разрешить агентам отправлять сообщения в ваши другие беседы. При отключении также отключается упоминание @@ в поле ввода.", "crossSessionMessageDisabledBanner": "Сообщения между беседами отключены, упоминание @@ недоступно.", "crossSessionMessageResume": "Включить снова", - "crossSessionMessageUpdateFailed": "Не удалось обновить настройку сообщений между беседами" + "crossSessionMessageUpdateFailed": "Не удалось обновить настройку сообщений между беседами", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json index 48f013ff994..eb36f48ea19 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json @@ -783,5 +783,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Planı genişlet", "collapse": "Planı daralt" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (planner/worker yönlendirme)", + "pill": "Auto · {{model}}", + "noCandidates": "Auto için kullanılabilir model yok" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json index a6cc97f8671..6f7d8976205 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json @@ -1412,5 +1412,20 @@ "crossSessionMessageDesc": "Aracıların diğer sohbetlerinize mesaj göndermesine izin verir. Kapatmak giriş alanındaki @@ sohbet etiketini de devre dışı bırakır.", "crossSessionMessageDisabledBanner": "Sohbetler arası mesajlar kapalı, @@ sohbet etiketi kullanılamıyor.", "crossSessionMessageResume": "Yeniden aç", - "crossSessionMessageUpdateFailed": "Sohbetler arası mesaj ayarı güncellenemedi" + "crossSessionMessageUpdateFailed": "Sohbetler arası mesaj ayarı güncellenemedi", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json index 30d1ce2c704..3e05472eb96 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json @@ -797,5 +797,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Розгорнути план", "collapse": "Згорнути план" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (маршрутизація planner/worker)", + "pill": "Auto · {{model}}", + "noCandidates": "Немає доступних моделей для Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json index 9dae7b4b74c..af4caa72fce 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json @@ -1427,5 +1427,20 @@ "crossSessionMessageDesc": "Дозволяє агентам надсилати повідомлення до ваших інших розмов. Вимкнення також відключає згадку @@ у полі введення.", "crossSessionMessageDisabledBanner": "Повідомлення між розмовами вимкнено, згадка @@ недоступна.", "crossSessionMessageResume": "Увімкнути знову", - "crossSessionMessageUpdateFailed": "Не вдалося оновити налаштування повідомлень між розмовами" + "crossSessionMessageUpdateFailed": "Не вдалося оновити налаштування повідомлень між розмовами", + "autoModel": { + "title": "Auto model routing", + "description": "Bind planner, worker, and utility slots for long-running aionrs agents. Phase 1 resolves a concrete model when you pick Auto; Phase 2 will switch slots without rebuilding the agent.", + "preference": "When slots are Automatic", + "preferenceCost": "Prefer lower cost", + "preferenceBalance": "Balance cost and quality", + "preferenceQuality": "Prefer higher quality", + "automatic": "Automatic", + "slot": { + "planner": "Planner model", + "worker": "Worker model", + "utility": "Utility model" + }, + "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json index ac049ff6d8d..b77afc09366 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "展开计划", "collapse": "收起计划" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(规划/执行路由)", + "pill": "Auto · {{model}}", + "noCandidates": "没有可用于 Auto 的模型" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json index 5470a235a1f..a3f4aa4bbad 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json @@ -1413,5 +1413,20 @@ "crossSessionMessageDesc": "允许 Agent 向你的其他会话发送消息。关闭后,输入框的 @@ 会话提及与投递能力同时停用。", "crossSessionMessageDisabledBanner": "跨会话消息已关闭,输入框的 @@ 会话提及不可用。", "crossSessionMessageResume": "重新开启", - "crossSessionMessageUpdateFailed": "跨会话消息设置更新失败" + "crossSessionMessageUpdateFailed": "跨会话消息设置更新失败", + "autoModel": { + "title": "Auto 模型路由", + "description": "为长时间运行的 aionrs 智能体绑定规划、执行与辅助模型槽位。第一阶段在选择 Auto 时解析为具体模型;第二阶段将在不重建智能体的情况下切换槽位。", + "preference": "槽位设为自动时", + "preferenceCost": "优先更低成本", + "preferenceBalance": "平衡成本与质量", + "preferenceQuality": "优先更高质量", + "automatic": "自动", + "slot": { + "planner": "规划模型", + "worker": "执行模型", + "utility": "辅助模型" + }, + "phaseNote": "当前 Auto 使用执行槽。规划/辅助槽切换将在 Core 支持不停止智能体更换模型后落地。" + } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json index 326f9a612d1..a853490eae9 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "展開計劃", "collapse": "收合計劃" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(規劃/執行路由)", + "pill": "Auto · {{model}}", + "noCandidates": "沒有可用於 Auto 的模型" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json index 2d1ba23c7c4..c0e6b692c8a 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json @@ -1414,5 +1414,20 @@ "crossSessionMessageDesc": "允許 Agent 向你的其他對話傳送訊息。關閉後,輸入框的 @@ 對話提及與投遞功能會同時停用。", "crossSessionMessageDisabledBanner": "跨對話訊息已關閉,輸入框的 @@ 對話提及不可用。", "crossSessionMessageResume": "重新開啟", - "crossSessionMessageUpdateFailed": "跨對話訊息設定更新失敗" + "crossSessionMessageUpdateFailed": "跨對話訊息設定更新失敗", + "autoModel": { + "title": "Auto 模型路由", + "description": "為長時間執行的 aionrs 智能體綁定規劃、執行與輔助模型槽位。第一階段在選擇 Auto 時解析為具體模型;第二階段將在不重建智能體的情況下切換槽位。", + "preference": "槽位設為自動時", + "preferenceCost": "優先更低成本", + "preferenceBalance": "平衡成本與品質", + "preferenceQuality": "優先更高品質", + "automatic": "自動", + "slot": { + "planner": "規劃模型", + "worker": "執行模型", + "utility": "輔助模型" + }, + "phaseNote": "目前 Auto 使用執行槽。規劃/輔助槽切換將在 Core 支援不停止智能體更換模型後落地。" + } } diff --git a/packages/desktop/src/renderer/utils/autoModel/constants.ts b/packages/desktop/src/renderer/utils/autoModel/constants.ts new file mode 100644 index 00000000000..9c01881f41c --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/constants.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AutoModelSettings, AutoModelSlotBinding, AutoModelSlots } from './types'; + +export const AUTO_MODEL_CONFIG_KEY = 'autoModel' as const; + +export const AUTO_MODEL_COMPOSITE_ID = '__aionui_auto__::auto'; + +export const defaultSlotBinding = (): AutoModelSlotBinding => ({ mode: 'automatic' }); + +export const defaultAutoModelSlots = (): AutoModelSlots => ({ + planner: defaultSlotBinding(), + worker: defaultSlotBinding(), + utility: defaultSlotBinding(), +}); + +export const defaultAutoModelSettings = (): AutoModelSettings => ({ + preference: 'balance', + slots: defaultAutoModelSlots(), +}); + +/** Composite id used in model pickers for the Auto row. */ +export const isAutoCompositeId = (id: string | null | undefined): boolean => id === AUTO_MODEL_COMPOSITE_ID; diff --git a/packages/desktop/src/renderer/utils/autoModel/index.ts b/packages/desktop/src/renderer/utils/autoModel/index.ts new file mode 100644 index 00000000000..4f17e94edb9 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +export type { AutoModelConversationExtra, AutoModelPhase, AutoModelPreference, AutoModelSettings, AutoModelSlotBinding, AutoModelSlots, ResolveAutoModelInput, ResolveAutoModelResult } from './types'; +export { + AUTO_MODEL_COMPOSITE_ID, + AUTO_MODEL_CONFIG_KEY, + defaultAutoModelSettings, + defaultAutoModelSlots, + defaultSlotBinding, + isAutoCompositeId, +} from './constants'; +export { resolveAutoModel } from './resolveAutoModel'; +export { normalizeAutoModelSettings, readAutoModelSettings, writeAutoModelSettings } from './settings'; +export { preferenceWeight, scoreModelForSlots } from './tierHeuristics'; diff --git a/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts b/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts new file mode 100644 index 00000000000..d6a2c805b95 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IProvider, TProviderWithModel } from '@/common/config/storage'; +import { hasSpecificModelCapability } from '@/common/utils/modelCapabilities'; +import { preferenceWeight, scoreModelForSlots } from './tierHeuristics'; +import type { ResolveAutoModelInput, ResolveAutoModelResult, AutoModelPhase } from './types'; + +const toProviderWithModel = (provider: IProvider, modelName: string): TProviderWithModel => + ({ ...provider, use_model: modelName }) as TProviderWithModel; + +const isHealthy = (provider: IProvider, modelName: string): boolean => { + const health = provider.model_health?.[modelName]; + if (!health) return true; + return health.status !== 'unhealthy'; +}; + +const supportsVision = (provider: IProvider, modelName: string): boolean => { + const vision = hasSpecificModelCapability(provider, modelName, 'vision'); + return vision !== false; +}; + +type Candidate = { provider: IProvider; modelName: string; score: number }; + +const collectCandidates = ( + input: ResolveAutoModelInput, + slot: AutoModelPhase +): Candidate[] => { + const out: Candidate[] = []; + for (const provider of input.providers) { + if (provider.enabled === false) continue; + for (const modelName of input.getAvailableModels(provider)) { + if (!isHealthy(provider, modelName)) continue; + if (input.requireVision && !supportsVision(provider, modelName)) continue; + const scores = scoreModelForSlots(modelName); + const score = preferenceWeight(input.settings.preference, slot, scores); + out.push({ provider, modelName, score }); + } + } + return out.sort((a, b) => b.score - a.score); +}; + +const resolveFixed = ( + input: ResolveAutoModelInput, + providerId: string, + modelName: string +): TProviderWithModel | null => { + const provider = input.providers.find((item) => item.id === providerId); + if (!provider || provider.enabled === false) return null; + const available = input.getAvailableModels(provider); + if (!available.includes(modelName)) return null; + if (!isHealthy(provider, modelName)) return null; + if (input.requireVision && !supportsVision(provider, modelName)) return null; + return toProviderWithModel(provider, modelName); +}; + +/** + * Resolve a concrete provider+model for the given Auto phase/slot. + * Phase 1 uses this for sticky resolve (typically `worker`); Phase 2 will + * call it again when the harness changes phase without rebuilding the agent. + */ +export const resolveAutoModel = (input: ResolveAutoModelInput): ResolveAutoModelResult => { + const binding = input.settings.slots[input.phase]; + + if (binding.mode === 'fixed') { + const fixed = resolveFixed(input, binding.provider_id, binding.model); + if (fixed) { + return { + model: fixed, + slot: input.phase, + reason: `fixed:${input.phase}`, + }; + } + } + + const candidates = collectCandidates(input, input.phase); + if (candidates.length === 0) { + // Fall back across seats so Auto never dead-ends when one seat is empty. + for (const fallback of fallbackOrder(input.phase)) { + const alt = collectCandidates(input, fallback); + if (alt[0]) { + return { + model: toProviderWithModel(alt[0].provider, alt[0].modelName), + slot: fallback, + reason: `automatic-fallback:${input.phase}->${fallback}`, + }; + } + } + throw new Error('No agent-capable models available for Auto routing'); + } + + const best = candidates[0]; + return { + model: toProviderWithModel(best.provider, best.modelName), + slot: input.phase, + reason: `automatic:${input.phase}`, + }; +}; + +const fallbackOrder = (phase: AutoModelPhase): AutoModelPhase[] => { + if (phase === 'planner') return ['worker', 'utility']; + if (phase === 'utility') return ['worker', 'planner']; + return ['planner', 'utility']; +}; diff --git a/packages/desktop/src/renderer/utils/autoModel/settings.ts b/packages/desktop/src/renderer/utils/autoModel/settings.ts new file mode 100644 index 00000000000..0a84f56f866 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/settings.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configService } from '@/common/config/configService'; +import { AUTO_MODEL_CONFIG_KEY, defaultAutoModelSettings } from './constants'; +import type { AutoModelSettings, AutoModelSlotBinding, AutoModelSlots } from './types'; + +const isSlotBinding = (value: unknown): value is AutoModelSlotBinding => { + if (!value || typeof value !== 'object') return false; + const mode = (value as { mode?: unknown }).mode; + if (mode === 'automatic') return true; + if (mode !== 'fixed') return false; + const fixed = value as { provider_id?: unknown; model?: unknown }; + return typeof fixed.provider_id === 'string' && typeof fixed.model === 'string'; +}; + +const normalizeSlots = (raw: unknown): AutoModelSlots => { + const defaults = defaultAutoModelSettings().slots; + if (!raw || typeof raw !== 'object') return defaults; + const slots = raw as Partial>; + return { + planner: isSlotBinding(slots.planner) ? slots.planner : defaults.planner, + worker: isSlotBinding(slots.worker) ? slots.worker : defaults.worker, + utility: isSlotBinding(slots.utility) ? slots.utility : defaults.utility, + }; +}; + +export const normalizeAutoModelSettings = (raw: unknown): AutoModelSettings => { + const defaults = defaultAutoModelSettings(); + if (!raw || typeof raw !== 'object') return defaults; + const value = raw as Partial; + const preference = + value.preference === 'cost' || value.preference === 'balance' || value.preference === 'quality' + ? value.preference + : defaults.preference; + return { + preference, + slots: normalizeSlots(value.slots), + }; +}; + +export const readAutoModelSettings = (): AutoModelSettings => { + return normalizeAutoModelSettings(configService.get(AUTO_MODEL_CONFIG_KEY)); +}; + +export const writeAutoModelSettings = async (settings: AutoModelSettings): Promise => { + await configService.set(AUTO_MODEL_CONFIG_KEY, normalizeAutoModelSettings(settings)); +}; diff --git a/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts new file mode 100644 index 00000000000..071a548b47e --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getBaseModelName } from '@/common/utils/modelCapabilities'; +import type { AutoModelPhase, AutoModelPreference } from './types'; + +type TierScore = { planner: number; worker: number; utility: number }; + +/** + * Score a model name for planner / worker / utility seats. + * Higher is better for that seat. Unknown names get a mild worker bias. + */ +export const scoreModelForSlots = (modelName: string): TierScore => { + const name = getBaseModelName(modelName); + + const utility = + matchScore(name, [/haiku/i, /mini/i, /nano/i, /flash-lite/i, /lite/i, /small/i]) + + (/(flash|instant|fast)/i.test(name) ? 2 : 0); + + const planner = + matchScore(name, [/opus/i, /o3/i, /o1/i, /gpt-5/i, /r1/i, /reasoning/i, /fable/i, /pro-preview/i]) + + (/(sonnet|gpt-4\.1(?!-mini)|qwen.*max|gemini.*pro)/i.test(name) ? 3 : 0); + + const worker = + matchScore(name, [/sonnet/i, /gpt-4\.1(?!-mini)/i, /gpt-4o(?!-mini)/i, /qwen.*max/i, /qwen.*plus/i, /gemini.*pro/i, /deepseek-chat/i, /coder/i]) + + (utility > 4 ? 1 : 0) + + (planner > 6 ? 2 : 4); + + return { planner, worker: Math.max(worker, 1), utility: Math.max(utility, planner > 6 ? 0 : utility) }; +}; + +const matchScore = (name: string, patterns: RegExp[]): number => { + let score = 0; + for (const pattern of patterns) { + if (pattern.test(name)) score += 4; + } + return score; +}; + +/** Preference biases which automatic candidate wins within a seat. */ +export const preferenceWeight = ( + preference: AutoModelPreference, + slot: AutoModelPhase, + scores: TierScore +): number => { + const base = scores[slot]; + if (preference === 'cost') { + if (slot === 'planner') return base - scores.utility * 0.5; + if (slot === 'worker') return base + scores.utility; + return base + 2; + } + if (preference === 'quality') { + if (slot === 'planner') return base + scores.planner; + if (slot === 'worker') return base + scores.planner * 0.3; + return base; + } + // balance + return base; +}; diff --git a/packages/desktop/src/renderer/utils/autoModel/types.ts b/packages/desktop/src/renderer/utils/autoModel/types.ts new file mode 100644 index 00000000000..aa7afb0c092 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/types.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IProvider, TProviderWithModel } from '@/common/config/storage'; + +/** Routing phase for planner/worker Auto (Phase 2 switches between these). */ +export type AutoModelPhase = 'planner' | 'worker' | 'utility'; + +/** Cost/quality bias when a slot is set to Automatic. */ +export type AutoModelPreference = 'cost' | 'balance' | 'quality'; + +/** One bound model, or automatic heuristic fill. */ +export type AutoModelSlotBinding = + | { mode: 'automatic' } + | { mode: 'fixed'; provider_id: string; model: string }; + +export type AutoModelSlots = { + planner: AutoModelSlotBinding; + worker: AutoModelSlotBinding; + utility: AutoModelSlotBinding; +}; + +/** Persisted user-level Auto settings (`configService` / client preferences). */ +export type AutoModelSettings = { + preference: AutoModelPreference; + slots: AutoModelSlots; +}; + +/** Conversation.extra.auto_model — UI flag + last resolve metadata. */ +export type AutoModelConversationExtra = { + enabled: boolean; + preference?: AutoModelPreference; + /** Active phase used for the last concrete resolve (Phase 1 defaults to worker). */ + phase?: AutoModelPhase; + last_resolved?: { + provider_id: string; + model: string; + slot: AutoModelPhase; + }; +}; + +export type ResolveAutoModelInput = { + phase: AutoModelPhase; + settings: AutoModelSettings; + providers: IProvider[]; + getAvailableModels: (provider: IProvider) => string[]; + requireVision?: boolean; +}; + +export type ResolveAutoModelResult = { + model: TProviderWithModel; + slot: AutoModelPhase; + reason: string; +}; diff --git a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts new file mode 100644 index 00000000000..20c601be3db --- /dev/null +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { IProvider } from '@/common/config/storage'; +import { defaultAutoModelSettings } from '@/renderer/utils/autoModel/constants'; +import { resolveAutoModel } from '@/renderer/utils/autoModel/resolveAutoModel'; +import { scoreModelForSlots } from '@/renderer/utils/autoModel/tierHeuristics'; + +const provider = (id: string, models: string[]): IProvider => + ({ + id, + name: id, + platform: 'openai', + base_url: 'https://example.com', + api_key: 'k', + models, + enabled: true, + }) as IProvider; + +describe('scoreModelForSlots', () => { + it('scores opus higher for planner than haiku', () => { + const opus = scoreModelForSlots('claude-opus-4'); + const haiku = scoreModelForSlots('claude-haiku-4'); + expect(opus.planner).toBeGreaterThan(haiku.planner); + expect(haiku.utility).toBeGreaterThan(opus.utility); + }); +}); + +describe('resolveAutoModel', () => { + const providers = [ + provider('p1', ['claude-haiku-4', 'claude-sonnet-4', 'claude-opus-4']), + ]; + const getAvailableModels = (p: IProvider) => p.models; + + it('picks a concrete model for the worker slot automatically', () => { + const result = resolveAutoModel({ + phase: 'worker', + settings: defaultAutoModelSettings(), + providers, + getAvailableModels, + }); + expect(result.model.use_model).toBeTruthy(); + expect(result.slot).toBe('worker'); + expect(result.reason.startsWith('automatic')).toBe(true); + }); + + it('honors a fixed planner binding', () => { + const settings = defaultAutoModelSettings(); + settings.slots.planner = { mode: 'fixed', provider_id: 'p1', model: 'claude-opus-4' }; + const result = resolveAutoModel({ + phase: 'planner', + settings, + providers, + getAvailableModels, + }); + expect(result.model.id).toBe('p1'); + expect(result.model.use_model).toBe('claude-opus-4'); + expect(result.reason).toBe('fixed:planner'); + }); + + it('falls back when the preferred seat has no candidates', () => { + const settings = defaultAutoModelSettings(); + settings.slots.utility = { mode: 'fixed', provider_id: 'missing', model: 'nope' }; + const result = resolveAutoModel({ + phase: 'utility', + settings, + providers, + getAvailableModels, + }); + expect(result.model.use_model).toBeTruthy(); + }); + + it('throws when no models are available', () => { + expect(() => + resolveAutoModel({ + phase: 'worker', + settings: defaultAutoModelSettings(), + providers: [], + getAvailableModels: () => [], + }) + ).toThrow(/No agent-capable models/); + }); +}); From f1124b99bf7cf741699f0da7e51a3bb20c4534de Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 22 Aug 2026 17:28:12 +0800 Subject: [PATCH 2/7] fix(conversation): repair Auto model CI typecheck, format, and mocks Closes #4143 Co-authored-by: Cursor --- docs/prds/conversations/auto-model.md | 6 +-- .../desktop/src/common/adapter/ipcBridge.ts | 14 ++++++ .../contents/ModelModalContent.tsx | 47 ++++++++++++------- .../components/ChatConversation.tsx | 3 +- .../aionrs/useAionrsModelSelection.ts | 6 +-- .../src/renderer/utils/autoModel/index.ts | 11 ++++- .../utils/autoModel/resolveAutoModel.ts | 5 +- .../utils/autoModel/tierHeuristics.ts | 17 ++++--- .../src/renderer/utils/autoModel/types.ts | 4 +- .../modelCapabilities.dom.test.tsx | 22 +++++++++ .../autoModel/resolveAutoModel.test.ts | 4 +- 11 files changed, 95 insertions(+), 44 deletions(-) diff --git a/docs/prds/conversations/auto-model.md b/docs/prds/conversations/auto-model.md index 17b79c69095..b8cc9611369 100644 --- a/docs/prds/conversations/auto-model.md +++ b/docs/prds/conversations/auto-model.md @@ -12,10 +12,10 @@ 本 PRD 分两阶段: -| 阶段 | 能力 | 用户可感知收益 | -| --- | --- | --- | +| 阶段 | 能力 | 用户可感知收益 | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | **Phase 1(本 PR)** | UI + 配置 + sticky Auto;选 Auto 时用 **worker** 槽解析出具体 `conversation.model`,并写 `extra.auto_model` | 可配置槽位;会话标记为 Auto;为 Phase 2 打基础 | -| **Phase 2** | Core 支持不重建 agent 切换模型;按 phase 在 planner/worker/utility 间切换 | 真正降低长跑成本 | +| **Phase 2** | Core 支持不重建 agent 切换模型;按 phase 在 planner/worker/utility 间切换 | 真正降低长跑成本 | **名词**: diff --git a/packages/desktop/src/common/adapter/ipcBridge.ts b/packages/desktop/src/common/adapter/ipcBridge.ts index 3666f8289e9..d764683e3c6 100644 --- a/packages/desktop/src/common/adapter/ipcBridge.ts +++ b/packages/desktop/src/common/adapter/ipcBridge.ts @@ -1869,6 +1869,20 @@ export interface ICreateConversationParams { remote_agent_id?: string; extra_skill_paths?: string[]; team_id?: string; + /** + * Sticky Auto routing flag (issue #4143). + * Concrete model is always on `model`; this only marks Auto selection. + */ + auto_model?: { + enabled: boolean; + preference?: 'cost' | 'balance' | 'quality'; + phase?: 'planner' | 'worker' | 'utility'; + last_resolved?: { + provider_id: string; + model: string; + slot: 'planner' | 'worker' | 'utility'; + }; + }; }; } diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx index d2ba7691932..04af6ac07bc 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx @@ -7,7 +7,18 @@ import { ipcBridge } from '@/common'; import type { IProvider } from '@/common/config/storage'; import { supportsOpenAiApiMode } from '@/common/utils/modelCapabilities'; -import { Button, Divider, Message, Popconfirm, Collapse, Tag, Switch, Tooltip, Select, Typography } from '@arco-design/web-react'; +import { + Button, + Divider, + Message, + Popconfirm, + Collapse, + Tag, + Switch, + Tooltip, + Select, + Typography, +} from '@arco-design/web-react'; import { DeleteFour, Heartbeat, @@ -474,23 +485,23 @@ const ModelModalContent: React.FC = () => {
{!data || data.length === 0 ? ( -
- -

{t('settings.noConfiguredModels')}

-

- {t('settings.needHelpConfigGuide')} - - {t('settings.configGuide')} - - {t('settings.configGuideSuffix')} -

-
- ) : ( +
+ +

{t('settings.noConfiguredModels')}

+

+ {t('settings.needHelpConfigGuide')} + + {t('settings.configGuide')} + + {t('settings.configGuideSuffix')} +

+
+ ) : ( (data || []).map((platform: IProvider) => { const key = platform.id; const isExpanded = collapseKey[platform.id] ?? false; diff --git a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx index 3eaa2287ee8..b3f35e9f875 100644 --- a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx +++ b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx @@ -167,6 +167,7 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid id: conversation.id, updates: { model: selected, + // merge_extra patch — same cast pattern as useAionrsMessage token usage. extra: { auto_model: autoEnabled ? { @@ -179,7 +180,7 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid }, } : { enabled: false }, - }, + } as TChatConversation['extra'], }, merge_extra: true, }); diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts index 7caaea54a11..9f4a828493e 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts @@ -25,11 +25,7 @@ export type AionrsModelSelection = { export type UseAionrsModelSelectionOptions = { initialModel: TProviderWithModel | undefined; initialAutoEnabled?: boolean; - onSelectModel: ( - provider: IProvider, - modelName: string, - meta?: { autoEnabled: boolean } - ) => Promise; + onSelectModel: (provider: IProvider, modelName: string, meta?: { autoEnabled: boolean }) => Promise; }; export const useAionrsModelSelection = ({ diff --git a/packages/desktop/src/renderer/utils/autoModel/index.ts b/packages/desktop/src/renderer/utils/autoModel/index.ts index 4f17e94edb9..9440e646ee2 100644 --- a/packages/desktop/src/renderer/utils/autoModel/index.ts +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -4,7 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type { AutoModelConversationExtra, AutoModelPhase, AutoModelPreference, AutoModelSettings, AutoModelSlotBinding, AutoModelSlots, ResolveAutoModelInput, ResolveAutoModelResult } from './types'; +export type { + AutoModelConversationExtra, + AutoModelPhase, + AutoModelPreference, + AutoModelSettings, + AutoModelSlotBinding, + AutoModelSlots, + ResolveAutoModelInput, + ResolveAutoModelResult, +} from './types'; export { AUTO_MODEL_COMPOSITE_ID, AUTO_MODEL_CONFIG_KEY, diff --git a/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts b/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts index d6a2c805b95..fd84740781d 100644 --- a/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts +++ b/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts @@ -25,10 +25,7 @@ const supportsVision = (provider: IProvider, modelName: string): boolean => { type Candidate = { provider: IProvider; modelName: string; score: number }; -const collectCandidates = ( - input: ResolveAutoModelInput, - slot: AutoModelPhase -): Candidate[] => { +const collectCandidates = (input: ResolveAutoModelInput, slot: AutoModelPhase): Candidate[] => { const out: Candidate[] = []; for (const provider of input.providers) { if (provider.enabled === false) continue; diff --git a/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts index 071a548b47e..b51ba5badb6 100644 --- a/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts +++ b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts @@ -25,7 +25,16 @@ export const scoreModelForSlots = (modelName: string): TierScore => { (/(sonnet|gpt-4\.1(?!-mini)|qwen.*max|gemini.*pro)/i.test(name) ? 3 : 0); const worker = - matchScore(name, [/sonnet/i, /gpt-4\.1(?!-mini)/i, /gpt-4o(?!-mini)/i, /qwen.*max/i, /qwen.*plus/i, /gemini.*pro/i, /deepseek-chat/i, /coder/i]) + + matchScore(name, [ + /sonnet/i, + /gpt-4\.1(?!-mini)/i, + /gpt-4o(?!-mini)/i, + /qwen.*max/i, + /qwen.*plus/i, + /gemini.*pro/i, + /deepseek-chat/i, + /coder/i, + ]) + (utility > 4 ? 1 : 0) + (planner > 6 ? 2 : 4); @@ -41,11 +50,7 @@ const matchScore = (name: string, patterns: RegExp[]): number => { }; /** Preference biases which automatic candidate wins within a seat. */ -export const preferenceWeight = ( - preference: AutoModelPreference, - slot: AutoModelPhase, - scores: TierScore -): number => { +export const preferenceWeight = (preference: AutoModelPreference, slot: AutoModelPhase, scores: TierScore): number => { const base = scores[slot]; if (preference === 'cost') { if (slot === 'planner') return base - scores.utility * 0.5; diff --git a/packages/desktop/src/renderer/utils/autoModel/types.ts b/packages/desktop/src/renderer/utils/autoModel/types.ts index aa7afb0c092..d37bc1a1efc 100644 --- a/packages/desktop/src/renderer/utils/autoModel/types.ts +++ b/packages/desktop/src/renderer/utils/autoModel/types.ts @@ -13,9 +13,7 @@ export type AutoModelPhase = 'planner' | 'worker' | 'utility'; export type AutoModelPreference = 'cost' | 'balance' | 'quality'; /** One bound model, or automatic heuristic fill. */ -export type AutoModelSlotBinding = - | { mode: 'automatic' } - | { mode: 'fixed'; provider_id: string; model: string }; +export type AutoModelSlotBinding = { mode: 'automatic' } | { mode: 'fixed'; provider_id: string; model: string }; export type AutoModelSlots = { planner: AutoModelSlotBinding; diff --git a/tests/unit/common-config/modelCapabilities.dom.test.tsx b/tests/unit/common-config/modelCapabilities.dom.test.tsx index cb86218e92d..ae865389d5f 100644 --- a/tests/unit/common-config/modelCapabilities.dom.test.tsx +++ b/tests/unit/common-config/modelCapabilities.dom.test.tsx @@ -116,6 +116,28 @@ vi.mock('@renderer/hooks/agent/useModeModeList', () => ({ vi.mock('@/renderer/hooks/agent/useModelProviderList', () => ({ useProvidersQuery: () => ({ data: mocks.providers, mutate: mocks.providerMutate }), + useModelProviderList: () => ({ + providers: mocks.providers, + getAvailableModels: (provider: { models?: string[] }) => provider.models ?? [], + formatModelLabel: (_provider: unknown, modelName?: string) => modelName || '', + }), +})); + +vi.mock('@/renderer/hooks/agent/useAutoModelSettings', () => ({ + useAutoModelSettings: () => ({ + settings: { + preference: 'balance', + slots: { + planner: { mode: 'automatic' }, + worker: { mode: 'automatic' }, + utility: { mode: 'automatic' }, + }, + }, + setPreference: vi.fn(), + setSlot: vi.fn(), + setSettings: vi.fn(), + }), + listSlotModelOptions: () => [], })); vi.mock('@/renderer/components/settings/SettingsModal/settingsViewContext', () => ({ diff --git a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts index 20c601be3db..1e52dde467b 100644 --- a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -31,9 +31,7 @@ describe('scoreModelForSlots', () => { }); describe('resolveAutoModel', () => { - const providers = [ - provider('p1', ['claude-haiku-4', 'claude-sonnet-4', 'claude-opus-4']), - ]; + const providers = [provider('p1', ['claude-haiku-4', 'claude-sonnet-4', 'claude-opus-4'])]; const getAvailableModels = (p: IProvider) => p.models; it('picks a concrete model for the worker slot automatically', () => { From 4149d978cefc6214489157af25c6ecb5d704094e Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 22 Aug 2026 17:44:32 +0800 Subject: [PATCH 3/7] feat(conversation): Auto phase routing with aionrs model hot-swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 for #4143: decide planner/worker before each send, hot-swap via config-options when provider matches, show Auto · phase/model. Depends on AionCore aionrs model hot-swap. Co-authored-by: Cursor --- docs/prds/conversations/auto-model.md | 77 ++++++------- .../components/ChatConversation.tsx | 9 +- .../platforms/aionrs/AionrsModelSelector.tsx | 5 +- .../platforms/aionrs/AionrsSendBox.tsx | 43 +++++++- .../aionrs/useAionrsModelSelection.ts | 25 ++++- .../pages/guid/components/GuidActionRow.tsx | 3 +- .../guid/components/GuidModelSelector.tsx | 3 +- .../pages/guid/hooks/useGuidModelSelection.ts | 2 +- .../renderer/pages/guid/hooks/useGuidSend.ts | 4 +- .../i18n/locales/de-DE/conversation.json | 2 +- .../services/i18n/locales/de-DE/settings.json | 2 +- .../i18n/locales/en-US/conversation.json | 2 +- .../services/i18n/locales/en-US/settings.json | 2 +- .../i18n/locales/es-ES/conversation.json | 2 +- .../services/i18n/locales/es-ES/settings.json | 2 +- .../i18n/locales/fa-IR/conversation.json | 2 +- .../services/i18n/locales/fa-IR/settings.json | 2 +- .../i18n/locales/fr-FR/conversation.json | 2 +- .../services/i18n/locales/fr-FR/settings.json | 2 +- .../i18n/locales/ja-JP/conversation.json | 2 +- .../services/i18n/locales/ja-JP/settings.json | 2 +- .../i18n/locales/ko-KR/conversation.json | 2 +- .../services/i18n/locales/ko-KR/settings.json | 2 +- .../i18n/locales/pt-BR/conversation.json | 2 +- .../services/i18n/locales/pt-BR/settings.json | 2 +- .../i18n/locales/ru-RU/conversation.json | 2 +- .../services/i18n/locales/ru-RU/settings.json | 2 +- .../i18n/locales/tr-TR/conversation.json | 2 +- .../services/i18n/locales/tr-TR/settings.json | 2 +- .../i18n/locales/uk-UA/conversation.json | 2 +- .../services/i18n/locales/uk-UA/settings.json | 2 +- .../i18n/locales/zh-CN/conversation.json | 2 +- .../services/i18n/locales/zh-CN/settings.json | 2 +- .../i18n/locales/zh-TW/conversation.json | 2 +- .../services/i18n/locales/zh-TW/settings.json | 2 +- .../utils/autoModel/applyAutoModelForTurn.ts | 103 ++++++++++++++++++ .../utils/autoModel/decideAutoModelPhase.ts | 46 ++++++++ .../src/renderer/utils/autoModel/index.ts | 4 + .../autoModel/decideAutoModelPhase.test.ts | 35 ++++++ 39 files changed, 326 insertions(+), 85 deletions(-) create mode 100644 packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/decideAutoModelPhase.ts create mode 100644 tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts diff --git a/docs/prds/conversations/auto-model.md b/docs/prds/conversations/auto-model.md index b8cc9611369..f59fc480df3 100644 --- a/docs/prds/conversations/auto-model.md +++ b/docs/prds/conversations/auto-model.md @@ -1,7 +1,7 @@ # Auto planner/worker model routing(aionrs) > Issue: [#4143](https://github.com/iOfficeAI/AionUi/issues/4143) -> 范围:aionrs 会话模型选择、模型设置页槽位绑定、会话 `extra.auto_model` +> 范围:aionrs 会话模型选择、模型设置页槽位绑定、会话 `extra.auto_model`、发前 phase 路由 > 非目标:ACP 平台、助手 `mode: auto`(「记住上次」)、把 Auto 设成默认模型 --- @@ -10,77 +10,66 @@ 长时间运行(如 7×24)的 aionrs agent 若全程使用高价模型,会在低价值回合上浪费成本。业界常见做法是 **Planner / Worker** 分槽:规划用强模型,执行磨信用便宜模型。 -本 PRD 分两阶段: - -| 阶段 | 能力 | 用户可感知收益 | -| -------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| **Phase 1(本 PR)** | UI + 配置 + sticky Auto;选 Auto 时用 **worker** 槽解析出具体 `conversation.model`,并写 `extra.auto_model` | 可配置槽位;会话标记为 Auto;为 Phase 2 打基础 | -| **Phase 2** | Core 支持不重建 agent 切换模型;按 phase 在 planner/worker/utility 间切换 | 真正降低长跑成本 | +| 阶段 | 能力 | 用户可感知收益 | +| --------------------- | ------------------------------------------------------------------------------------- | -------------------------------- | +| **Phase 1** | UI + 配置 + sticky Auto;选 Auto 时解析具体 `conversation.model` + `extra.auto_model` | 可配置槽位;会话标记为 Auto | +| **Phase 2(本变更)** | 同 provider 热切换;发前按 phase 在 planner↔worker 间路由 | 长跑可真正换模省成本(同供应商) | **名词**: - **Auto**:产品级「自动路由」入口,与助手权限模式 `mode: auto`(Remember last used)无关。 -- **槽位**:`planner` / `worker` / `utility`,可固定绑定某模型,或设为 Automatic(按偏好打分)。 +- **槽位**:`planner` / `worker` / `utility`,可固定绑定某模型,或设为 Automatic。 - **Sentinel**:选择器内部 id `__aionui_auto__::auto`,**永不**作为后端 model 上报。 --- -## (F-AUTO-01) 选择器出现 Auto 入口 [新增] - -**用户故事**:作为 aionrs 用户,我希望在模型列表顶部看到 Auto,而不是只能手选单个模型。 +## Phase 1(已落地) -**正常流程**: - -1. 打开 aionrs 对话或 Guid 新建页的模型选择器。 -2. 列表顶部有 **Auto** 分组 / 选项。 -3. 选中后 pill 显示 `Auto · {具体模型名}`。 - -**验收**: - -- [ ] 桌面下拉与移动 action sheet 均有 Auto。 -- [ ] 选中后 `conversation.model` 为具体 provider+model,不是 sentinel。 -- [ ] `extra.auto_model.enabled === true`,且含 `last_resolved`。 +见 F-AUTO-01 … F-AUTO-04:选择器 Auto、设置槽位、退出 Auto、失败提示。 --- -## (F-AUTO-02) 设置页绑定槽位与偏好 [新增] - -**用户故事**:作为用户,我希望在模型设置里为 planner/worker/utility 指定模型或 Automatic,并选择成本/质量偏好。 +## Phase 2 -**验收**: - -- [ ] 设置写入 `configService` 键 `autoModel`。 -- [ ] Automatic 时按 preference 对可用模型打分选槽;固定绑定时校验模型仍存在,否则回落 Automatic。 -- [ ] 文案说明 Phase 1 实际使用 worker 槽。 +### (F-AUTO-05) 同 provider 热切换 [新增 · 依赖 AionCore] ---- +**Core**: -## (F-AUTO-03) 手动选模型退出 Auto [不变语义] +- `PUT .../config-options/model` 对 aionrs 生效:调用 `AgentEngine::apply_config_update`,不 kill。 +- 回合中切换 → `pending_next_turn`,下一 turn 发送前应用。 +- `PATCH conversation.model` 在 **同一 `provider_id`** 时改为热切换;跨 provider 仍 kill+rebuild。 **验收**: -- [ ] 用户再选具体模型后 `auto_model.enabled` 为 false;pill 不再显示 Auto 前缀。 +- [ ] 同 provider 换模后会话 MCP/上下文保留,无需重建 agent。 +- [ ] 跨 provider 仍走重建路径。 ---- +### (F-AUTO-06) 发前 phase 路由 [新增 · AionUi] -## (F-AUTO-04) 无可用模型时失败提示 [新增] +**流程**(`AionrsSendBox.executeCommand`,Auto 开启时): -**验收**: +1. `decideAutoModelPhase`:首 turn / replan 话术 / worker 连续失败 ≥2 → `planner`,否则 `worker`。 +2. `resolveAutoModel(phase)` → 具体模型。 +3. 同 provider → `setConfigOption('model')` + `conversation.update`(merge `extra.auto_model`)。 +4. Pill:`Auto · {phase}/{model}`。 -- [ ] resolve 失败时 Message 提示,不写入无效 model。 +**验收**: ---- +- [ ] 首条用户消息使用 planner 槽。 +- [ ] 常规续写使用 worker 槽。 +- [ ] 输入含 replan / 重新规划 等 → 回到 planner。 +- [ ] Core 尚无热切换时:setConfigOption 失败可降级为仅 persist(可能 rebuild)。 -## Phase 2(后续,不在本 PR) +### (F-AUTO-07) Utility / 细粒度分类器 [后续] -- Core:同一 agent 实例上切换 model,不 stop/rebuild。 -- 按任务 phase 在 planner ↔ worker(及 utility)间切换,并更新 `extra.auto_model.phase` / `last_resolved`。 -- 可选:turn 级启发式路由(更接近 Cursor / OpenSquilla)。 +- Utility 槽与子代理默认路由。 +- OpenSquilla 式 turn classifier。 --- ## 实现备注 -- 解析逻辑:`packages/desktop/src/renderer/utils/autoModel/` -- 会话标记:`conversation.extra.auto_model` +- UI:`packages/desktop/src/renderer/utils/autoModel/` +- Core:`AionrsAgentManager` config-option `model` + `ConversationService::update` same-provider 分支 - 勿把 Auto 做成全局默认模型;用户显式选择才启用。 +- Phase 2 UI 需配对含热切换的 AionCore 版本才能完全免重建。 diff --git a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx index b3f35e9f875..a676c51fd18 100644 --- a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx +++ b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx @@ -154,8 +154,10 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid const onSelectModel = useCallback( async (_provider: IProvider, modelName: string, meta?: { autoEnabled: boolean }) => { const selected = { ..._provider, use_model: modelName } as TProviderWithModel; - // Kill running agent on model switch — will be rebuilt with new model on next message - if (runtimeView.activeTurnId) { + const sameProvider = conversation.model?.id === selected.id; + // Same-provider switches are hot-swapped by Core; only stop/rebuild when + // the provider itself changes (or Core lacks hot-swap and rebuilds later). + if (runtimeView.activeTurnId && !sameProvider) { const result = await ipcBridge.conversation.stop.invoke({ conversation_id: conversation.id, turn_id: runtimeView.activeTurnId, @@ -186,12 +188,13 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid }); return Boolean(ok); }, - [conversation.id, runtimeView] + [conversation.id, conversation.model?.id, runtimeView] ); const modelSelection = useAionrsModelSelection({ initialModel: conversation.model, initialAutoEnabled: Boolean(conversation.extra?.auto_model?.enabled), + initialAutoPhase: conversation.extra?.auto_model?.phase, onSelectModel, }); // Project conversations get the Layout-level Explorer column (stage3 FULL); diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx index f3c74773bc7..3dfcc6c90a9 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsModelSelector.tsx @@ -69,14 +69,15 @@ const AionrsModelSelector: React.FC<{ ); } - const { providers, getAvailableModels, handleSelectModel, handleSelectAuto, autoEnabled } = selection; + const { providers, getAvailableModels, handleSelectModel, handleSelectAuto, autoEnabled, autoPhase } = selection; const label = getModelDisplayLabel({ selected_value: current_model?.use_model, selectedLabel: autoEnabled ? t('conversation.autoModel.pill', { + phase: autoPhase || 'worker', model: current_model?.use_model || '', - defaultValue: `Auto · ${current_model?.use_model || ''}`, + defaultValue: `Auto · ${autoPhase || 'worker'}/${current_model?.use_model || ''}`, }) : current_model?.use_model || '', defaultModelLabel, diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx index dac868bfc13..5d312c8fbcc 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx @@ -48,9 +48,10 @@ import { type ChatFileRef, isChatFileRef, uploadFileRef } from '@/common/types/c import { localSelectionItems, mergeFileSelectionItems } from '@/renderer/utils/file/fileSelection'; import { collectChatFileRefs, splitChatFileRefs } from '@/renderer/utils/file/messageFiles'; import type { AgentModeOption } from '@/renderer/utils/model/agentTypes'; +import { applyAutoModelForTurn, persistAutoModelConversationState } from '@/renderer/utils/autoModel'; import { Button, Message, Tag } from '@arco-design/web-react'; import { Brain, Lightning, MagicHat, Shield } from '@icon-park/react'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { classifyConversationBusyError } from '../conversationBusyError'; import { useAionrsMessage } from './useAionrsMessage'; @@ -140,7 +141,8 @@ const AionrsSendBox: React.FC<{ })); const { t } = useTranslation(); const { checkAndUpdateTitle } = useAutoTitle(); - const { current_model } = modelSelection; + const { current_model, autoEnabled, syncAutoResolved, providers, getAvailableModels } = modelSelection; + const autoUserTurnsRef = useRef(0); const teamPermission = useTeamPermission(); const propagateMode = teamPermission?.propagateMode; @@ -188,6 +190,10 @@ const AionrsSendBox: React.FC<{ const runtimeMode = runtimeConfig.mode; const runtimeThoughtLevel = runtimeConfig.thoughtLevel; + useEffect(() => { + autoUserTurnsRef.current = 0; + }, [conversation_id]); + useEffect(() => { if (!runtimeMode?.currentValue) return; setCurrentMode(runtimeMode.currentValue); @@ -263,6 +269,26 @@ const AionrsSendBox: React.FC<{ // ChatFileRef to an absolute path and injects the [[AION_FILES]] marker at // the send edge — the front-end no longer builds paths nor the marker. try { + if (autoEnabled && !teamSendMessage) { + try { + const applied = await applyAutoModelForTurn({ + conversationId: conversation_id, + userInput: input, + hasPriorUserTurns: autoUserTurnsRef.current > 0, + currentModel: current_model, + providers, + getAvailableModels, + setConfigOption: (optionId, value) => runtimeConfig.setConfigOption(optionId, value), + persistModel: (model, phase) => persistAutoModelConversationState(conversation_id, model, phase), + }); + syncAutoResolved(applied.model, applied.phase); + } catch (error) { + console.error('[AionrsSendBox] Auto phase routing failed:', error); + Message.warning(t('conversation.autoModel.noCandidates', { defaultValue: 'No available models for Auto' })); + throw error; + } + } + void checkAndUpdateTitle(conversation_id, input); if (teamSendMessage) { await teamSendMessage({ input, files }); @@ -283,6 +309,9 @@ const AionrsSendBox: React.FC<{ // no-op for this platform. sessions, }); + if (autoEnabled) { + autoUserTurnsRef.current += 1; + } setActiveMsgId(res.msg_id); markSendAccepted(res.turn_id, res.runtime, res.msg_id); emitter.emit('chat.history.refresh'); @@ -311,14 +340,19 @@ const AionrsSendBox: React.FC<{ } }, [ + autoEnabled, checkAndUpdateTitle, conversation_id, - current_model?.use_model, + current_model, + getAvailableModels, markSendAccepted, markSendFailed, markSendStarted, + providers, + runtimeConfig, setActiveMsgId, setWaitingResponse, + syncAutoResolved, t, teamPermission, teamSendMessage, @@ -563,8 +597,9 @@ const AionrsSendBox: React.FC<{ modeOptions.find((opt) => opt.active)?.label ?? t('agentMode.default', { defaultValue: 'Default' }); const currentModelLabel = modelSelection.autoEnabled ? t('conversation.autoModel.pill', { + phase: modelSelection.autoPhase || 'worker', model: modelSelection.current_model?.use_model || '', - defaultValue: `Auto · ${modelSelection.current_model?.use_model || ''}`, + defaultValue: `Auto · ${modelSelection.autoPhase || 'worker'}/${modelSelection.current_model?.use_model || ''}`, }) : modelSelection.current_model?.use_model || t('conversation.welcome.selectModel'); diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts index 9f4a828493e..4f19fc96977 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/useAionrsModelSelection.ts @@ -6,7 +6,7 @@ import type { IProvider, TProviderWithModel } from '@/common/config/storage'; import { useModelProviderList } from '@/renderer/hooks/agent/useModelProviderList'; -import { readAutoModelSettings, resolveAutoModel } from '@/renderer/utils/autoModel'; +import { readAutoModelSettings, resolveAutoModel, type AutoModelPhase } from '@/renderer/utils/autoModel'; import { Message } from '@arco-design/web-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,27 +15,34 @@ export type AionrsModelSelection = { current_model?: TProviderWithModel; /** Conversation is in Auto mode (concrete model still on current_model). */ autoEnabled: boolean; + /** Last Auto phase used for pill display (Phase 2). */ + autoPhase?: AutoModelPhase; providers: IProvider[]; getAvailableModels: (provider: IProvider) => string[]; handleSelectModel: (provider: IProvider, modelName: string) => Promise; handleSelectAuto: () => Promise; + /** Sync local selection after per-turn Auto routing. */ + syncAutoResolved: (model: TProviderWithModel, phase: AutoModelPhase) => void; getDisplayModelName: (modelName?: string) => string; }; export type UseAionrsModelSelectionOptions = { initialModel: TProviderWithModel | undefined; initialAutoEnabled?: boolean; + initialAutoPhase?: AutoModelPhase; onSelectModel: (provider: IProvider, modelName: string, meta?: { autoEnabled: boolean }) => Promise; }; export const useAionrsModelSelection = ({ initialModel, initialAutoEnabled = false, + initialAutoPhase, onSelectModel, }: UseAionrsModelSelectionOptions): AionrsModelSelection => { const { t } = useTranslation(); const [current_model, setCurrentModel] = useState(initialModel); const [autoEnabled, setAutoEnabled] = useState(initialAutoEnabled); + const [autoPhase, setAutoPhase] = useState(initialAutoPhase); useEffect(() => { setCurrentModel(initialModel); @@ -45,6 +52,10 @@ export const useAionrsModelSelection = ({ setAutoEnabled(initialAutoEnabled); }, [initialAutoEnabled]); + useEffect(() => { + setAutoPhase(initialAutoPhase); + }, [initialAutoPhase]); + const { providers: allProviders, getAvailableModels, formatModelLabel } = useModelProviderList(); // AionCore does not support Google Auth — filter it out @@ -63,6 +74,7 @@ export const useAionrsModelSelection = ({ if (ok) { setCurrentModel(selected); setAutoEnabled(false); + setAutoPhase(undefined); } }, [onSelectModel] @@ -70,6 +82,8 @@ export const useAionrsModelSelection = ({ const handleSelectAuto = useCallback(async () => { try { + // Sticky pick uses worker as the default concrete model; Phase 2 send path + // re-resolves planner/worker per turn. const resolved = resolveAutoModel({ phase: 'worker', settings: readAutoModelSettings(), @@ -82,6 +96,7 @@ export const useAionrsModelSelection = ({ if (ok) { setCurrentModel(resolved.model); setAutoEnabled(true); + setAutoPhase('worker'); } } catch (error) { console.error('Failed to resolve Auto model:', error); @@ -89,6 +104,12 @@ export const useAionrsModelSelection = ({ } }, [getAvailableModels, onSelectModel, providers, t]); + const syncAutoResolved = useCallback((model: TProviderWithModel, phase: AutoModelPhase) => { + setCurrentModel(model); + setAutoEnabled(true); + setAutoPhase(phase); + }, []); + const getDisplayModelName = useCallback( (modelName?: string) => { if (!modelName) return ''; @@ -102,10 +123,12 @@ export const useAionrsModelSelection = ({ return { current_model, autoEnabled, + autoPhase, providers, getAvailableModels, handleSelectModel, handleSelectAuto, + syncAutoResolved, getDisplayModelName, }; }; diff --git a/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx b/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx index 9c919172619..dff580a8eb6 100644 --- a/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx +++ b/packages/desktop/src/renderer/pages/guid/components/GuidActionRow.tsx @@ -256,8 +256,9 @@ const GuidActionRow: React.FC = ({ ]; currentModelLabel = autoEnabled ? t('conversation.autoModel.pill', { + phase: 'planner', model: current_model?.use_model || '', - defaultValue: `Auto · ${current_model?.use_model || ''}`, + defaultValue: `Auto · planner/${current_model?.use_model || ''}`, }) : current_model?.use_model || ''; onModelSelect = (key) => { diff --git a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx index 64e9891368a..1643a65b1d6 100644 --- a/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx +++ b/packages/desktop/src/renderer/pages/guid/components/GuidModelSelector.tsx @@ -71,8 +71,9 @@ const GuidModelSelector: React.FC = ({ if (!current_model?.use_model) return ''; if (autoEnabled) { return t('conversation.autoModel.pill', { + phase: 'planner', model: current_model.use_model, - defaultValue: `Auto · ${current_model.use_model}`, + defaultValue: `Auto · planner/${current_model.use_model}`, }); } return current_model.use_model; diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts index b2f17ad92bb..96882a71fff 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidModelSelection.ts @@ -83,7 +83,7 @@ export const useGuidModelSelection = (agentKey: ProviderAgentKey = 'aionrs'): Gu const selectAutoModel = useCallback(async () => { try { const resolved = resolveAutoModel({ - phase: 'worker', + phase: 'planner', settings: readAutoModelSettings(), providers: modelList, getAvailableModels, diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts index 2e6362e99db..cece1457923 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts @@ -196,11 +196,11 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { auto_model: autoEnabled ? { enabled: true, - phase: 'worker', + phase: 'planner', last_resolved: { provider_id: current_model.id, model: current_model.use_model, - slot: 'worker', + slot: 'planner', }, } : { enabled: false }, diff --git a/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json index 2c72de7c103..795c5c34691 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/de-DE/conversation.json @@ -786,7 +786,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (Planner/Worker-Routing)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Keine verfügbaren Modelle für Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json b/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json index 5fb39ca7d67..892ca57907b 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/de-DE/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json index c637eb0f615..ca989292aba 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (planner/worker routing)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "No available models for Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json b/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json index d2aa2dadda3..c42e84f2faa 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/en-US/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json index 5ac47a39206..4410257cd85 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/es-ES/conversation.json @@ -786,7 +786,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (enrutamiento planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "No hay modelos disponibles para Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json b/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json index 5ef45f22398..599ad6db215 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/es-ES/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json index 6b288c986c5..4865c592500 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (مسیریابی planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "مدلی برای Auto در دسترس نیست" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json index a5444051374..fd3069020f2 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fa-IR/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json index 3591dd0a5c0..4993e9aef2d 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (routage planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Aucun modèle disponible pour Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json index bda2b25a756..130dc6e3060 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/fr-FR/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json index 9034e3fafaa..c7372b847d1 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto(プランナー/ワーカー振り分け)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Auto に使えるモデルがありません" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json index e410842c852..0da7b5ed3d5 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ja-JP/settings.json @@ -1428,6 +1428,6 @@ "worker": "Worker モデル", "utility": "Utility モデル" }, - "phaseNote": "現在 Auto は worker スロットを使用します。planner/utility 切替は、Core がエージェント停止なしでモデル変更できるようになってから実装されます。" + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json index 5b74fdc1756..b11e9cb4871 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto(플래너/워커 라우팅)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Auto에 사용할 수 있는 모델이 없습니다" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json index d413802743b..83384b07115 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ko-KR/settings.json @@ -1428,6 +1428,6 @@ "worker": "Worker 모델", "utility": "Utility 모델" }, - "phaseNote": "현재 Auto는 worker 슬롯을 사용합니다. planner/utility 전환은 Core가 에이전트를 중지하지 않고 모델을 바꿀 수 있을 때 제공됩니다." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json index 28f01df3022..d0141a042a6 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/conversation.json @@ -791,7 +791,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (roteamento planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Nenhum modelo disponível para Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json index 28366e82d66..ce662cffec4 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/pt-BR/settings.json @@ -1429,6 +1429,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json index 280d9c857bd..2c1ef50e733 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/conversation.json @@ -801,7 +801,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (маршрутизация planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Нет доступных моделей для Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json index 108f3a62b8a..fe515d764f1 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/ru-RU/settings.json @@ -1440,6 +1440,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json index eb36f48ea19..a676580eaee 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/conversation.json @@ -787,7 +787,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (planner/worker yönlendirme)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Auto için kullanılabilir model yok" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json index 6f7d8976205..347e30bb832 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/tr-TR/settings.json @@ -1426,6 +1426,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json index 3e05472eb96..2321488eccc 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/conversation.json @@ -801,7 +801,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto (маршрутизація planner/worker)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "Немає доступних моделей для Auto" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json index af4caa72fce..d68140b7227 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/uk-UA/settings.json @@ -1441,6 +1441,6 @@ "worker": "Worker model", "utility": "Utility model" }, - "phaseNote": "Today Auto uses the worker slot. Planner/utility switches land when Core can change models without stopping the agent." + "phaseNote": "On the same provider, Auto hot-swaps planner/worker models before each send without rebuilding the agent. Cross-provider switches still rebuild." } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json index b77afc09366..0d48f83fdd3 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto(规划/执行路由)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "没有可用于 Auto 的模型" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json index a3f4aa4bbad..2038be4f2a5 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-CN/settings.json @@ -1427,6 +1427,6 @@ "worker": "执行模型", "utility": "辅助模型" }, - "phaseNote": "当前 Auto 使用执行槽。规划/辅助槽切换将在 Core 支持不停止智能体更换模型后落地。" + "phaseNote": "同一供应商下 Auto 会在发送前按规划/执行阶段热切换模型,无需重建智能体。跨供应商仍会重建。" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json index a853490eae9..9c7eb3e8b8d 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/conversation.json @@ -785,7 +785,7 @@ "autoModel": { "groupTitle": "Auto", "optionLabel": "Auto(規劃/執行路由)", - "pill": "Auto · {{model}}", + "pill": "Auto · {{phase}}/{{model}}", "noCandidates": "沒有可用於 Auto 的模型" } } diff --git a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json index c0e6b692c8a..d459c5e14fb 100644 --- a/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json +++ b/packages/desktop/src/renderer/services/i18n/locales/zh-TW/settings.json @@ -1428,6 +1428,6 @@ "worker": "執行模型", "utility": "輔助模型" }, - "phaseNote": "目前 Auto 使用執行槽。規劃/輔助槽切換將在 Core 支援不停止智能體更換模型後落地。" + "phaseNote": "同一供應商下 Auto 會在發送前依規劃/執行階段熱切換模型,無需重建智能體。跨供應商仍會重建。" } } diff --git a/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts b/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts new file mode 100644 index 00000000000..8c800ea3e79 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ipcBridge } from '@/common'; +import type { IProvider, TChatConversation, TProviderWithModel } from '@/common/config/storage'; +import { + decideAutoModelPhase, + readAutoModelSettings, + resolveAutoModel, + type AutoModelPhase, +} from '@/renderer/utils/autoModel'; + +export type ApplyAutoModelForTurnInput = { + conversationId: string; + userInput: string; + hasPriorUserTurns: boolean; + consecutiveWorkerFailures?: number; + currentModel: TProviderWithModel | undefined; + providers: IProvider[]; + getAvailableModels: (provider: IProvider) => string[]; + /** Same-provider hot-swap via config-options/model. */ + setConfigOption: (optionId: string, value: string) => Promise; + /** Persist conversation.model + extra.auto_model (merge). Returns false on failure. */ + persistModel: (model: TProviderWithModel, phase: AutoModelPhase) => Promise; +}; + +export type ApplyAutoModelForTurnResult = { + phase: AutoModelPhase; + model: TProviderWithModel; + hotSwapped: boolean; +}; + +/** + * Resolve Auto phase model for this turn and apply it. + * Same provider → config-option hot-swap + persist (no stop). + * Cross provider → persist only (Core may rebuild); caller should still send. + */ +export const applyAutoModelForTurn = async ( + input: ApplyAutoModelForTurnInput +): Promise => { + const phase = decideAutoModelPhase({ + hasPriorUserTurns: input.hasPriorUserTurns, + userInput: input.userInput, + consecutiveWorkerFailures: input.consecutiveWorkerFailures, + }); + const resolved = resolveAutoModel({ + phase, + settings: readAutoModelSettings(), + providers: input.providers, + getAvailableModels: input.getAvailableModels, + }); + const model = resolved.model; + const sameProvider = Boolean(input.currentModel?.id && input.currentModel.id === model.id); + const modelName = model.use_model; + const alreadyOnModel = sameProvider && input.currentModel?.use_model === modelName; + + if (sameProvider && !alreadyOnModel) { + try { + await input.setConfigOption('model', modelName); + } catch (error) { + // Older Core builds reject aionrs `model` config options — fall through to + // conversation.update persistence (may rebuild until Core hot-swap lands). + console.warn('[applyAutoModelForTurn] model hot-swap unavailable, persisting only:', error); + } + } + + const ok = await input.persistModel(model, phase); + if (!ok) { + throw new Error('Failed to persist Auto model for turn'); + } + + return { phase, model, hotSwapped: sameProvider && !alreadyOnModel }; +}; + +export const persistAutoModelConversationState = async ( + conversationId: string, + model: TProviderWithModel, + phase: AutoModelPhase +): Promise => { + return Boolean( + await ipcBridge.conversation.update.invoke({ + id: conversationId, + updates: { + model, + extra: { + auto_model: { + enabled: true, + phase, + last_resolved: { + provider_id: model.id, + model: model.use_model, + slot: phase, + }, + }, + } as TChatConversation['extra'], + }, + merge_extra: true, + }) + ); +}; diff --git a/packages/desktop/src/renderer/utils/autoModel/decideAutoModelPhase.ts b/packages/desktop/src/renderer/utils/autoModel/decideAutoModelPhase.ts new file mode 100644 index 00000000000..ae5bc0e61f6 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/decideAutoModelPhase.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AutoModelPhase } from './types'; + +export type DecideAutoModelPhaseInput = { + /** True when the conversation already has at least one user turn. */ + hasPriorUserTurns: boolean; + userInput: string; + /** Worker turns that failed in a row; ≥2 escalates to planner. */ + consecutiveWorkerFailures?: number; +}; + +const REPLAN_PATTERNS: RegExp[] = [ + /\breplan\b/i, + /\bre-?plan\b/i, + /\brethink\b/i, + /\bstart over\b/i, + /\btry a different (approach|plan|strategy)\b/i, + /\bchange (the )?plan\b/i, + /重新规划/, + /换个方案/, + /换个思路/, + /从头再来/, + /重新设计/, +]; + +export const looksLikeReplanRequest = (userInput: string): boolean => { + const text = userInput.trim(); + if (!text) return false; + return REPLAN_PATTERNS.some((pattern) => pattern.test(text)); +}; + +/** + * Phase-2 Auto routing: prefer planner for first turn / replan / worker failure + * escalation; otherwise worker. Utility is reserved for later subagent work. + */ +export const decideAutoModelPhase = (input: DecideAutoModelPhaseInput): AutoModelPhase => { + if (!input.hasPriorUserTurns) return 'planner'; + if ((input.consecutiveWorkerFailures ?? 0) >= 2) return 'planner'; + if (looksLikeReplanRequest(input.userInput)) return 'planner'; + return 'worker'; +}; diff --git a/packages/desktop/src/renderer/utils/autoModel/index.ts b/packages/desktop/src/renderer/utils/autoModel/index.ts index 9440e646ee2..4b7d4fe408c 100644 --- a/packages/desktop/src/renderer/utils/autoModel/index.ts +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -23,5 +23,9 @@ export { isAutoCompositeId, } from './constants'; export { resolveAutoModel } from './resolveAutoModel'; +export { decideAutoModelPhase, looksLikeReplanRequest } from './decideAutoModelPhase'; +export { applyAutoModelForTurn, persistAutoModelConversationState } from './applyAutoModelForTurn'; +export type { ApplyAutoModelForTurnInput, ApplyAutoModelForTurnResult } from './applyAutoModelForTurn'; +export type { DecideAutoModelPhaseInput } from './decideAutoModelPhase'; export { normalizeAutoModelSettings, readAutoModelSettings, writeAutoModelSettings } from './settings'; export { preferenceWeight, scoreModelForSlots } from './tierHeuristics'; diff --git a/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts new file mode 100644 index 00000000000..3c9cdbf0110 --- /dev/null +++ b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { decideAutoModelPhase, looksLikeReplanRequest } from '@/renderer/utils/autoModel/decideAutoModelPhase'; + +describe('looksLikeReplanRequest', () => { + it('detects english and chinese replan cues', () => { + expect(looksLikeReplanRequest('please replan the approach')).toBe(true); + expect(looksLikeReplanRequest('换个方案试试')).toBe(true); + expect(looksLikeReplanRequest('continue with step 3')).toBe(false); + }); +}); + +describe('decideAutoModelPhase', () => { + it('uses planner on the first user turn', () => { + expect(decideAutoModelPhase({ hasPriorUserTurns: false, userInput: 'build a todo app' })).toBe('planner'); + }); + + it('uses worker for routine follow-ups', () => { + expect(decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'ok continue' })).toBe('worker'); + }); + + it('escalates to planner after worker failures or replan text', () => { + expect( + decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'ok', consecutiveWorkerFailures: 2 }) + ).toBe('planner'); + expect(decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'rethink the architecture' })).toBe( + 'planner' + ); + }); +}); From 88c789c377b72b2703b008b2e70dd256c6ef685f Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 22 Aug 2026 17:52:36 +0800 Subject: [PATCH 4/7] style(conversation): oxfmt Auto phase routing tests Co-authored-by: Cursor --- tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts index 3c9cdbf0110..d8e4c48a53d 100644 --- a/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts +++ b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts @@ -25,11 +25,9 @@ describe('decideAutoModelPhase', () => { }); it('escalates to planner after worker failures or replan text', () => { - expect( - decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'ok', consecutiveWorkerFailures: 2 }) - ).toBe('planner'); - expect(decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'rethink the architecture' })).toBe( + expect(decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'ok', consecutiveWorkerFailures: 2 })).toBe( 'planner' ); + expect(decideAutoModelPhase({ hasPriorUserTurns: true, userInput: 'rethink the architecture' })).toBe('planner'); }); }); From 6ca8e316dc4d8602681e071231e21606937c1bca Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Fri, 28 Aug 2026 08:52:51 +0800 Subject: [PATCH 5/7] fix(conversation): address Auto model review feedback Restore stop-on-model-change during active turns, pass requireVision when attachments include images, fix tier heuristics/preference scoring, and hide Auto settings when no providers are configured. Co-authored-by: Cursor --- .../contents/ModelModalContent.tsx | 487 +++++++++--------- .../components/ChatConversation.tsx | 7 +- .../platforms/aionrs/AionrsSendBox.tsx | 7 +- .../utils/autoModel/applyAutoModelForTurn.ts | 3 + .../utils/autoModel/tierHeuristics.ts | 86 ++-- .../autoModel/resolveAutoModel.test.ts | 26 +- 6 files changed, 329 insertions(+), 287 deletions(-) diff --git a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx index 04af6ac07bc..d485d56be21 100644 --- a/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx +++ b/packages/desktop/src/renderer/components/settings/SettingsModal/contents/ModelModalContent.tsx @@ -483,7 +483,6 @@ const ModelModalContent: React.FC = () => { {/* Content Area */}
- {!data || data.length === 0 ? (
@@ -502,268 +501,274 @@ const ModelModalContent: React.FC = () => {

) : ( - (data || []).map((platform: IProvider) => { - const key = platform.id; - const isExpanded = collapseKey[platform.id] ?? false; - return ( - { - const expanded = activeKeys.includes('image-generation'); - setCollapseKey((prev) => ({ ...prev, [platform.id]: expanded })); - }} - key={key} - bordered - expandIconPosition='left' - className={`[&_.arco-collapse-item]:!border-0 [&_.arco-collapse-item]:!rounded-12px [&_.arco-collapse-item]:!overflow-hidden [&_.arco-collapse-item]:!bg-[var(--color-bg-2)] [&_.arco-collapse-item-header]:!bg-[var(--fill-0)] [&_.arco-collapse-item-header]:!ps-36px [&_.arco-collapse-item-header]:!pe-12px [&_.arco-collapse-item-header]:!py-8px [&_.arco-collapse-item-header]:transition-colors [&_.arco-collapse-item-header]:hover:!bg-[var(--color-bg-2)] [&_.arco-collapse-item-header]:!gap-8px [&_.arco-collapse-item-header-title]:!min-w-0 [&_.arco-collapse-item-header-icon]:!text-2 [&_.arco-collapse-item-header:hover_.arco-collapse-item-header-icon]:!text-1 [&_.arco-collapse-item-content]:!bg-fill-1 [&_.arco-collapse-item-content-box]:!px-10px [&_.arco-collapse-item-content-box]:!py-8px [&_.arco-collapse-item-content]:!border-t [&_.arco-collapse-item-content]:!border-[var(--color-border-2)] ${ - isExpanded - ? '[&_.arco-collapse-item-header]:!rounded-t-12px [&_.arco-collapse-item-header]:!rounded-b-0 [&_.arco-collapse-item-content]:!rounded-b-12px' - : '[&_.arco-collapse-item-header]:!rounded-12px' - }`} - > - - - {platform.name} - -
{ - e.stopPropagation(); - }} - onMouseDown={(e) => { - e.stopPropagation(); - }} - > - - setCollapseKey((prev) => ({ ...prev, [platform.id]: !isExpanded }))} - > - {t('settings.modelCount')}({(platform.models ?? []).length}) + <> + + {(data || []).map((platform: IProvider) => { + const key = platform.id; + const isExpanded = collapseKey[platform.id] ?? false; + return ( + { + const expanded = activeKeys.includes('image-generation'); + setCollapseKey((prev) => ({ ...prev, [platform.id]: expanded })); + }} + key={key} + bordered + expandIconPosition='left' + className={`[&_.arco-collapse-item]:!border-0 [&_.arco-collapse-item]:!rounded-12px [&_.arco-collapse-item]:!overflow-hidden [&_.arco-collapse-item]:!bg-[var(--color-bg-2)] [&_.arco-collapse-item-header]:!bg-[var(--fill-0)] [&_.arco-collapse-item-header]:!ps-36px [&_.arco-collapse-item-header]:!pe-12px [&_.arco-collapse-item-header]:!py-8px [&_.arco-collapse-item-header]:transition-colors [&_.arco-collapse-item-header]:hover:!bg-[var(--color-bg-2)] [&_.arco-collapse-item-header]:!gap-8px [&_.arco-collapse-item-header-title]:!min-w-0 [&_.arco-collapse-item-header-icon]:!text-2 [&_.arco-collapse-item-header:hover_.arco-collapse-item-header-icon]:!text-1 [&_.arco-collapse-item-content]:!bg-fill-1 [&_.arco-collapse-item-content-box]:!px-10px [&_.arco-collapse-item-content-box]:!py-8px [&_.arco-collapse-item-content]:!border-t [&_.arco-collapse-item-content]:!border-[var(--color-border-2)] ${ + isExpanded + ? '[&_.arco-collapse-item-header]:!rounded-t-12px [&_.arco-collapse-item-header]:!rounded-b-0 [&_.arco-collapse-item-content]:!rounded-b-12px' + : '[&_.arco-collapse-item-header]:!rounded-12px' + }`} + > + + + {platform.name} + +
{ + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + + setCollapseKey((prev) => ({ ...prev, [platform.id]: !isExpanded }))} + > + {t('settings.modelCount')}({(platform.models ?? []).length}) + + | + editModalCtrl.open({ data: platform })} + > + {t('settings.apiKeyCount')}({getApiKeyCount(platform.api_key)}) + - | - editModalCtrl.open({ data: platform })} - > - {t('settings.apiKeyCount')}({getApiKeyCount(platform.api_key)}) + + {(platform.models ?? []).length} / {getApiKeyCount(platform.api_key)} - - - {(platform.models ?? []).length} / {getApiKeyCount(platform.api_key)} - - {/* 供应商启用开关 / Provider enable switch */} - toggleProviderEnabled(platform)} - /> -
-
-
- } - > - {(platform.models ?? []).map((model: string, index: number, arr: string[]) => { - const isNewApiProvider = isNewApiPlatform(platform.platform); - const modelProtocol = platform.model_protocols?.[model] || 'openai'; - const modelSettings = platform.model_settings?.[model]; - const imageInput = modelSettings?.image_input ?? 'auto'; - const showOpenAiApiMode = supportsOpenAiApiMode(platform.platform, modelProtocol); - const model_health = platform.model_health?.[model]; - const healthStatus = model_health?.status || 'unknown'; - - return ( -
-
-
- {/* 健康状态指示器 / Health status indicator */} - {healthStatus !== 'unknown' && ( + } + > + {(platform.models ?? []).map((model: string, index: number, arr: string[]) => { + const isNewApiProvider = isNewApiPlatform(platform.platform); + const modelProtocol = platform.model_protocols?.[model] || 'openai'; + const modelSettings = platform.model_settings?.[model]; + const imageInput = modelSettings?.image_input ?? 'auto'; + const showOpenAiApiMode = supportsOpenAiApiMode(platform.platform, modelProtocol); + const model_health = platform.model_health?.[model]; + const healthStatus = model_health?.status || 'unknown'; + + return ( +
+
+
+ {/* 健康状态指示器 / Health status indicator */} + {healthStatus !== 'unknown' && ( + +
+ {healthStatus === 'healthy' ? '✅' : '❌'} + + {healthStatus === 'healthy' ? t('common.success') : t('common.failed')} + +
+ {model_health?.latency && ( +
+ {t('settings.latency')}: {model_health.latency}ms +
+ )} + {model_health?.error && ( +
{model_health.error}
+ )} + {model_health?.last_check && ( +
+ {t('mcp.lastCheck')}:{' '} + {formatDateTime(model_health.last_check, i18n.language)} +
+ )} +
+ } + > +
+ + )} + + {model} + + {/* New API 协议标签(点击循环切换)/ New API protocol badge (click to cycle) */} + {isNewApiProvider && ( + { + const nextProtocol = getNextProtocol(modelProtocol); + const newProtocols = { ...platform.model_protocols }; + newProtocols[model] = nextProtocol; + updatePlatform({ ...platform, model_protocols: newProtocols }, () => {}); + }} + > + {getProtocolLabel(modelProtocol)} + + )} + -
- {healthStatus === 'healthy' ? '✅' : '❌'} - - {healthStatus === 'healthy' ? t('common.success') : t('common.failed')} - -
- {model_health?.latency && ( -
- {t('settings.latency')}: {model_health.latency}ms -
- )} - {model_health?.error && ( -
{model_health.error}
- )} - {model_health?.last_check && ( -
- {t('mcp.lastCheck')}: {formatDateTime(model_health.last_check, i18n.language)} -
- )} -
+ imageInput === 'supported' + ? t('settings.imageInputSupported') + : imageInput === 'unsupported' + ? t('settings.imageInputUnsupported') + : t('settings.imageInputAuto') } > -
+ {imageInput !== 'unsupported' ? ( + + ) : ( + + )} + + + + {showOpenAiApiMode && ( + + {modelSettings?.openai_api_mode === 'responses' + ? t('settings.openAiApiModeResponses') + : modelSettings?.openai_api_mode === 'chat_completions' + ? t('settings.openAiApiModeChatCompletions') + : t('settings.openAiApiModeAuto')} + + )} + + {/* 模型启用开关 / Model enable switch */} + toggleModelEnabled(platform, model, checked)} + /> +
+ +
+ +
- -
- -
+ {index < arr.length - 1 && }
- {index < arr.length - 1 && } -
- ); - })} - - - ); - }) + ); + })} + + + ); + })} + )}
diff --git a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx index a676c51fd18..3828895dca9 100644 --- a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx +++ b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx @@ -154,10 +154,9 @@ const AionrsConversationPanel: React.FC<{ conversation: AionrsConversation; slid const onSelectModel = useCallback( async (_provider: IProvider, modelName: string, meta?: { autoEnabled: boolean }) => { const selected = { ..._provider, use_model: modelName } as TProviderWithModel; - const sameProvider = conversation.model?.id === selected.id; - // Same-provider switches are hot-swapped by Core; only stop/rebuild when - // the provider itself changes (or Core lacks hot-swap and rebuilds later). - if (runtimeView.activeTurnId && !sameProvider) { + // Always stop an active turn when the user changes model manually. Same-provider + // hot-swap (Core #923) is only used on the Auto per-turn routing path. + if (runtimeView.activeTurnId) { const result = await ipcBridge.conversation.stop.invoke({ conversation_id: conversation.id, turn_id: runtimeView.activeTurnId, diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx index 5d312c8fbcc..5930ce93cb3 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx @@ -44,11 +44,12 @@ import type { SessionRef } from '@/common/adapter/ipcBridge'; import CrossSessionDisabledBanner from '@/renderer/components/chat/CrossSessionDisabledBanner'; import { useCrossSessionMessageEnabled } from '@/renderer/hooks/chat/useCrossSessionMessageEnabled'; import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; -import { type ChatFileRef, isChatFileRef, uploadFileRef } from '@/common/types/chatFile'; +import { type ChatFileRef, chatFileRefPath, isChatFileRef, uploadFileRef } from '@/common/types/chatFile'; import { localSelectionItems, mergeFileSelectionItems } from '@/renderer/utils/file/fileSelection'; import { collectChatFileRefs, splitChatFileRefs } from '@/renderer/utils/file/messageFiles'; import type { AgentModeOption } from '@/renderer/utils/model/agentTypes'; import { applyAutoModelForTurn, persistAutoModelConversationState } from '@/renderer/utils/autoModel'; +import { getFileTypeInfo } from '@/renderer/utils/file/fileType'; import { Button, Message, Tag } from '@arco-design/web-react'; import { Brain, Lightning, MagicHat, Shield } from '@icon-park/react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -65,6 +66,9 @@ const configErrorMessageKey = (error: unknown) => { return 'agent.config.failed'; }; +const chatFileRefsRequireVision = (files: ChatFileRef[]): boolean => + files.some((file) => getFileTypeInfo(chatFileRefPath(file)).contentType === 'image'); + const toModeLabel = (value: string): string => value .split('_') @@ -275,6 +279,7 @@ const AionrsSendBox: React.FC<{ conversationId: conversation_id, userInput: input, hasPriorUserTurns: autoUserTurnsRef.current > 0, + requireVision: chatFileRefsRequireVision(files), currentModel: current_model, providers, getAvailableModels, diff --git a/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts b/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts index 8c800ea3e79..6f27daab7bc 100644 --- a/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts +++ b/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts @@ -18,6 +18,8 @@ export type ApplyAutoModelForTurnInput = { userInput: string; hasPriorUserTurns: boolean; consecutiveWorkerFailures?: number; + /** When true, only vision-capable models are considered. */ + requireVision?: boolean; currentModel: TProviderWithModel | undefined; providers: IProvider[]; getAvailableModels: (provider: IProvider) => string[]; @@ -51,6 +53,7 @@ export const applyAutoModelForTurn = async ( settings: readAutoModelSettings(), providers: input.providers, getAvailableModels: input.getAvailableModels, + requireVision: input.requireVision, }); const model = resolved.model; const sameProvider = Boolean(input.currentModel?.id && input.currentModel.id === model.id); diff --git a/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts index b51ba5badb6..e9209d70af8 100644 --- a/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts +++ b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts @@ -9,59 +9,65 @@ import type { AutoModelPhase, AutoModelPreference } from './types'; type TierScore = { planner: number; worker: number; utility: number }; +const isUtilityModel = (name: string): boolean => + /haiku|nano|mini|flash-lite|small|instant|fast\b/i.test(name) || (/flash/i.test(name) && !/pro/i.test(name)); + /** - * Score a model name for planner / worker / utility seats. - * Higher is better for that seat. Unknown names get a mild worker bias. + * Frontier reasoning models — mutually exclusive tiers to avoid double-counting + * (e.g. `gemini-3-pro-preview` matching both `pro-preview` and `gemini.*pro`). */ -export const scoreModelForSlots = (modelName: string): TierScore => { - const name = getBaseModelName(modelName); - - const utility = - matchScore(name, [/haiku/i, /mini/i, /nano/i, /flash-lite/i, /lite/i, /small/i]) + - (/(flash|instant|fast)/i.test(name) ? 2 : 0); - - const planner = - matchScore(name, [/opus/i, /o3/i, /o1/i, /gpt-5/i, /r1/i, /reasoning/i, /fable/i, /pro-preview/i]) + - (/(sonnet|gpt-4\.1(?!-mini)|qwen.*max|gemini.*pro)/i.test(name) ? 3 : 0); +const frontierPlannerScore = (name: string): number => { + if (/opus/i.test(name)) return 10; + if (/o3|o1\b/i.test(name)) return 9; + if (/gpt-5/i.test(name)) return 8; + if (/r1|reasoning/i.test(name)) return 7; + if (/pro-preview/i.test(name)) return 6; + return 0; +}; - const worker = - matchScore(name, [ - /sonnet/i, - /gpt-4\.1(?!-mini)/i, - /gpt-4o(?!-mini)/i, - /qwen.*max/i, - /qwen.*plus/i, - /gemini.*pro/i, - /deepseek-chat/i, - /coder/i, - ]) + - (utility > 4 ? 1 : 0) + - (planner > 6 ? 2 : 4); +const midPlannerScore = (name: string): number => { + if (/sonnet/i.test(name)) return 5; + if (/qwen.*max/i.test(name)) return 4; + if (/gemini-[\d.]+-pro/i.test(name)) return 4; + return 0; +}; - return { planner, worker: Math.max(worker, 1), utility: Math.max(utility, planner > 6 ? 0 : utility) }; +const workerScore = (name: string, planner: number, utility: boolean): number => { + if (utility) return 2; + if (/sonnet/i.test(name)) return 8; + if (/gpt-4o(?!-mini)/i.test(name)) return 7; + if (/gpt-4\.1(?!-mini)/i.test(name)) return 6; + if (/deepseek-chat|deepseek-v3/i.test(name)) return 6; + if (/qwen3-coder-plus/i.test(name)) return 5; + if (/qwen.*plus/i.test(name)) return 5; + if (/coder/i.test(name)) return 4; + if (planner >= 6) return 3; + return 3; }; -const matchScore = (name: string, patterns: RegExp[]): number => { - let score = 0; - for (const pattern of patterns) { - if (pattern.test(name)) score += 4; - } - return score; +/** + * Score a model name for planner / worker / utility seats. + * Higher is better for that seat. Unknown names get a mild worker bias. + */ +export const scoreModelForSlots = (modelName: string): TierScore => { + const name = getBaseModelName(modelName).toLowerCase(); + const utilityModel = isUtilityModel(name); + const planner = Math.max(frontierPlannerScore(name), midPlannerScore(name), 1); + const worker = Math.max(workerScore(name, planner, utilityModel), 1); + const utility = utilityModel ? 8 : planner >= 8 ? 1 : 2; + + return { planner, worker, utility }; }; /** Preference biases which automatic candidate wins within a seat. */ export const preferenceWeight = (preference: AutoModelPreference, slot: AutoModelPhase, scores: TierScore): number => { const base = scores[slot]; if (preference === 'cost') { - if (slot === 'planner') return base - scores.utility * 0.5; - if (slot === 'worker') return base + scores.utility; - return base + 2; + return base + scores.utility * 2 - scores.planner * 0.5; } if (preference === 'quality') { - if (slot === 'planner') return base + scores.planner; - if (slot === 'worker') return base + scores.planner * 0.3; - return base; + return base + scores.planner * 1.5 - scores.utility * 0.5; } - // balance - return base; + // balance — slight cost awareness without overriding seat fit + return base + scores.utility * 0.3; }; diff --git a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts index 1e52dde467b..e75660c43bd 100644 --- a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; import type { IProvider } from '@/common/config/storage'; import { defaultAutoModelSettings } from '@/renderer/utils/autoModel/constants'; import { resolveAutoModel } from '@/renderer/utils/autoModel/resolveAutoModel'; -import { scoreModelForSlots } from '@/renderer/utils/autoModel/tierHeuristics'; +import { preferenceWeight, scoreModelForSlots } from '@/renderer/utils/autoModel/tierHeuristics'; const provider = (id: string, models: string[]): IProvider => ({ @@ -28,6 +28,30 @@ describe('scoreModelForSlots', () => { expect(opus.planner).toBeGreaterThan(haiku.planner); expect(haiku.utility).toBeGreaterThan(opus.utility); }); + + it('does not double-boost gemini pro preview for planner', () => { + const preview = scoreModelForSlots('gemini-3-pro-preview'); + const sonnet = scoreModelForSlots('claude-sonnet-4'); + expect(preview.planner).toBeLessThanOrEqual(6); + expect(preview.planner).toBeGreaterThan(sonnet.planner); + }); + + it('prefers sonnet over qwen3-coder-plus for worker seat', () => { + const sonnet = scoreModelForSlots('claude-sonnet-4'); + const coder = scoreModelForSlots('qwen3-coder-plus'); + expect(sonnet.worker).toBeGreaterThan(coder.worker); + }); + + it('applies preference weights for cost vs quality', () => { + const haiku = scoreModelForSlots('claude-haiku-4'); + const opus = scoreModelForSlots('claude-opus-4'); + const costHaiku = preferenceWeight('cost', 'worker', haiku); + const costOpus = preferenceWeight('cost', 'worker', opus); + const qualityHaiku = preferenceWeight('quality', 'planner', haiku); + const qualityOpus = preferenceWeight('quality', 'planner', opus); + expect(costHaiku).toBeGreaterThan(costOpus); + expect(qualityOpus).toBeGreaterThan(qualityHaiku); + }); }); describe('resolveAutoModel', () => { From 12f03f254fe6e090c71caa3a463ffcb9ce30dbeb Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 29 Aug 2026 09:17:11 +0800 Subject: [PATCH 6/7] fix(conversation): derive Auto phase from message history Use loaded user turns instead of session-local counter so reopened Auto conversations route to worker correctly. Add applyAutoModelForTurn and vision/history helper tests. Co-authored-by: Cursor --- .../platforms/aionrs/AionrsSendBox.tsx | 28 ++-- .../utils/autoModel/conversationTurns.ts | 11 ++ .../src/renderer/utils/autoModel/index.ts | 2 + .../src/renderer/utils/autoModel/vision.ts | 13 ++ .../autoModel/applyAutoModelForTurn.test.ts | 120 ++++++++++++++++++ tests/unit/renderer/autoModel/helpers.test.ts | 37 ++++++ .../autoModel/resolveAutoModel.test.ts | 26 ++++ .../conversation/AionrsSendBox.dom.test.tsx | 3 + 8 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 packages/desktop/src/renderer/utils/autoModel/conversationTurns.ts create mode 100644 packages/desktop/src/renderer/utils/autoModel/vision.ts create mode 100644 tests/unit/renderer/autoModel/applyAutoModelForTurn.test.ts create mode 100644 tests/unit/renderer/autoModel/helpers.test.ts diff --git a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx index 5930ce93cb3..3e746815800 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx @@ -44,15 +44,20 @@ import type { SessionRef } from '@/common/adapter/ipcBridge'; import CrossSessionDisabledBanner from '@/renderer/components/chat/CrossSessionDisabledBanner'; import { useCrossSessionMessageEnabled } from '@/renderer/hooks/chat/useCrossSessionMessageEnabled'; import { emitter, useAddEventListener } from '@/renderer/utils/emitter'; -import { type ChatFileRef, chatFileRefPath, isChatFileRef, uploadFileRef } from '@/common/types/chatFile'; +import { type ChatFileRef, isChatFileRef, uploadFileRef } from '@/common/types/chatFile'; import { localSelectionItems, mergeFileSelectionItems } from '@/renderer/utils/file/fileSelection'; import { collectChatFileRefs, splitChatFileRefs } from '@/renderer/utils/file/messageFiles'; import type { AgentModeOption } from '@/renderer/utils/model/agentTypes'; -import { applyAutoModelForTurn, persistAutoModelConversationState } from '@/renderer/utils/autoModel'; -import { getFileTypeInfo } from '@/renderer/utils/file/fileType'; +import { + applyAutoModelForTurn, + chatFileRefsRequireVision, + conversationHasUserTurns, + persistAutoModelConversationState, +} from '@/renderer/utils/autoModel'; +import { useMessageList } from '@/renderer/pages/conversation/Messages/hooks'; import { Button, Message, Tag } from '@arco-design/web-react'; import { Brain, Lightning, MagicHat, Shield } from '@icon-park/react'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { classifyConversationBusyError } from '../conversationBusyError'; import { useAionrsMessage } from './useAionrsMessage'; @@ -66,9 +71,6 @@ const configErrorMessageKey = (error: unknown) => { return 'agent.config.failed'; }; -const chatFileRefsRequireVision = (files: ChatFileRef[]): boolean => - files.some((file) => getFileTypeInfo(chatFileRefPath(file)).contentType === 'image'); - const toModeLabel = (value: string): string => value .split('_') @@ -146,7 +148,8 @@ const AionrsSendBox: React.FC<{ const { t } = useTranslation(); const { checkAndUpdateTitle } = useAutoTitle(); const { current_model, autoEnabled, syncAutoResolved, providers, getAvailableModels } = modelSelection; - const autoUserTurnsRef = useRef(0); + const messageList = useMessageList(); + const messageListRef = useLatestRef(messageList); const teamPermission = useTeamPermission(); const propagateMode = teamPermission?.propagateMode; @@ -194,10 +197,6 @@ const AionrsSendBox: React.FC<{ const runtimeMode = runtimeConfig.mode; const runtimeThoughtLevel = runtimeConfig.thoughtLevel; - useEffect(() => { - autoUserTurnsRef.current = 0; - }, [conversation_id]); - useEffect(() => { if (!runtimeMode?.currentValue) return; setCurrentMode(runtimeMode.currentValue); @@ -278,7 +277,7 @@ const AionrsSendBox: React.FC<{ const applied = await applyAutoModelForTurn({ conversationId: conversation_id, userInput: input, - hasPriorUserTurns: autoUserTurnsRef.current > 0, + hasPriorUserTurns: conversationHasUserTurns(messageListRef.current), requireVision: chatFileRefsRequireVision(files), currentModel: current_model, providers, @@ -314,9 +313,6 @@ const AionrsSendBox: React.FC<{ // no-op for this platform. sessions, }); - if (autoEnabled) { - autoUserTurnsRef.current += 1; - } setActiveMsgId(res.msg_id); markSendAccepted(res.turn_id, res.runtime, res.msg_id); emitter.emit('chat.history.refresh'); diff --git a/packages/desktop/src/renderer/utils/autoModel/conversationTurns.ts b/packages/desktop/src/renderer/utils/autoModel/conversationTurns.ts new file mode 100644 index 00000000000..fc46cfffc63 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/conversationTurns.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TMessage } from '@/common/chat/chatLib'; + +/** Whether the conversation already has at least one user text turn in history. */ +export const conversationHasUserTurns = (messages: TMessage[]): boolean => + messages.some((message) => message.type === 'text' && message.position === 'right'); diff --git a/packages/desktop/src/renderer/utils/autoModel/index.ts b/packages/desktop/src/renderer/utils/autoModel/index.ts index 4b7d4fe408c..a27680da891 100644 --- a/packages/desktop/src/renderer/utils/autoModel/index.ts +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -29,3 +29,5 @@ export type { ApplyAutoModelForTurnInput, ApplyAutoModelForTurnResult } from './ export type { DecideAutoModelPhaseInput } from './decideAutoModelPhase'; export { normalizeAutoModelSettings, readAutoModelSettings, writeAutoModelSettings } from './settings'; export { preferenceWeight, scoreModelForSlots } from './tierHeuristics'; +export { chatFileRefsRequireVision } from './vision'; +export { conversationHasUserTurns } from './conversationTurns'; diff --git a/packages/desktop/src/renderer/utils/autoModel/vision.ts b/packages/desktop/src/renderer/utils/autoModel/vision.ts new file mode 100644 index 00000000000..b31a825c5a7 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/vision.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChatFileRef } from '@/common/types/chatFile'; +import { chatFileRefPath } from '@/common/types/chatFile'; +import { getFileTypeInfo } from '@/renderer/utils/file/fileType'; + +/** True when any attachment is an image (vision-capable model required). */ +export const chatFileRefsRequireVision = (files: ChatFileRef[]): boolean => + files.some((file) => getFileTypeInfo(chatFileRefPath(file)).contentType === 'image'); diff --git a/tests/unit/renderer/autoModel/applyAutoModelForTurn.test.ts b/tests/unit/renderer/autoModel/applyAutoModelForTurn.test.ts new file mode 100644 index 00000000000..91cdef76ef3 --- /dev/null +++ b/tests/unit/renderer/autoModel/applyAutoModelForTurn.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IProvider, TProviderWithModel } from '@/common/config/storage'; +import { applyAutoModelForTurn } from '@/renderer/utils/autoModel/applyAutoModelForTurn'; + +const readAutoModelSettingsMock = vi.fn(); +const decideAutoModelPhaseMock = vi.fn(); +const resolveAutoModelMock = vi.fn(); + +vi.mock('@/renderer/utils/autoModel/settings', () => ({ + readAutoModelSettings: () => readAutoModelSettingsMock(), +})); + +vi.mock('@/renderer/utils/autoModel/decideAutoModelPhase', () => ({ + decideAutoModelPhase: (...args: unknown[]) => decideAutoModelPhaseMock(...args), +})); + +vi.mock('@/renderer/utils/autoModel/resolveAutoModel', () => ({ + resolveAutoModel: (...args: unknown[]) => resolveAutoModelMock(...args), +})); + +const provider = { + id: 'p1', + name: 'p1', + platform: 'openai', + models: ['claude-sonnet-4', 'claude-opus-4'], + enabled: true, +} as IProvider; + +const sonnet = { ...provider, use_model: 'claude-sonnet-4' } as TProviderWithModel; +const opus = { ...provider, use_model: 'claude-opus-4' } as TProviderWithModel; + +describe('applyAutoModelForTurn', () => { + beforeEach(() => { + vi.clearAllMocks(); + readAutoModelSettingsMock.mockReturnValue({ preference: 'balance', slots: {} }); + decideAutoModelPhaseMock.mockReturnValue('worker'); + resolveAutoModelMock.mockReturnValue({ model: sonnet, slot: 'worker', reason: 'automatic:worker' }); + }); + + it('hot-swaps within the same provider without persisting failure', async () => { + const setConfigOption = vi.fn().mockResolvedValue(undefined); + const persistModel = vi.fn().mockResolvedValue(true); + + const result = await applyAutoModelForTurn({ + conversationId: 'c1', + userInput: 'continue', + hasPriorUserTurns: true, + currentModel: opus, + providers: [provider], + getAvailableModels: (p) => p.models, + setConfigOption, + persistModel, + }); + + expect(setConfigOption).toHaveBeenCalledWith('model', 'claude-sonnet-4'); + expect(persistModel).toHaveBeenCalledWith(sonnet, 'worker'); + expect(result.hotSwapped).toBe(true); + }); + + it('persists when hot-swap is unavailable on older Core builds', async () => { + const setConfigOption = vi.fn().mockRejectedValue(new Error('unsupported')); + const persistModel = vi.fn().mockResolvedValue(true); + + await applyAutoModelForTurn({ + conversationId: 'c1', + userInput: 'continue', + hasPriorUserTurns: true, + currentModel: opus, + providers: [provider], + getAvailableModels: (p) => p.models, + setConfigOption, + persistModel, + }); + + expect(persistModel).toHaveBeenCalledWith(sonnet, 'worker'); + }); + + it('forwards requireVision to resolveAutoModel', async () => { + const persistModel = vi.fn().mockResolvedValue(true); + + await applyAutoModelForTurn({ + conversationId: 'c1', + userInput: 'describe this', + hasPriorUserTurns: false, + requireVision: true, + currentModel: sonnet, + providers: [provider], + getAvailableModels: (p) => p.models, + setConfigOption: vi.fn(), + persistModel, + }); + + expect(resolveAutoModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + requireVision: true, + }) + ); + }); + + it('throws when persistence fails', async () => { + await expect( + applyAutoModelForTurn({ + conversationId: 'c1', + userInput: 'continue', + hasPriorUserTurns: true, + currentModel: sonnet, + providers: [provider], + getAvailableModels: (p) => p.models, + setConfigOption: vi.fn(), + persistModel: vi.fn().mockResolvedValue(false), + }) + ).rejects.toThrow(/Failed to persist Auto model/); + }); +}); diff --git a/tests/unit/renderer/autoModel/helpers.test.ts b/tests/unit/renderer/autoModel/helpers.test.ts new file mode 100644 index 00000000000..f597f6c6347 --- /dev/null +++ b/tests/unit/renderer/autoModel/helpers.test.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 AionUi (aionui.com) + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { TMessage } from '@/common/chat/chatLib'; +import { conversationHasUserTurns } from '@/renderer/utils/autoModel/conversationTurns'; +import { chatFileRefsRequireVision } from '@/renderer/utils/autoModel/vision'; +import { projectFileRef, uploadFileRef } from '@/common/types/chatFile'; + +describe('conversationHasUserTurns', () => { + it('returns false for empty history', () => { + expect(conversationHasUserTurns([])).toBe(false); + }); + + it('detects prior user text turns', () => { + const messages = [ + { type: 'text', position: 'right' }, + { type: 'text', position: 'left' }, + ] as TMessage[]; + expect(conversationHasUserTurns(messages)).toBe(true); + }); + + it('ignores assistant-only history', () => { + const messages = [{ type: 'text', position: 'left' }] as TMessage[]; + expect(conversationHasUserTurns(messages)).toBe(false); + }); +}); + +describe('chatFileRefsRequireVision', () => { + it('returns true when any attachment is an image', () => { + expect(chatFileRefsRequireVision([projectFileRef('pe-1', 'shots/ui.png')])).toBe(true); + expect(chatFileRefsRequireVision([uploadFileRef('/tmp/readme.md')])).toBe(false); + }); +}); diff --git a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts index e75660c43bd..fc22e0ffdee 100644 --- a/tests/unit/renderer/autoModel/resolveAutoModel.test.ts +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -106,4 +106,30 @@ describe('resolveAutoModel', () => { }) ).toThrow(/No agent-capable models/); }); + + it('picks sonnet over haiku for worker under balance preference', () => { + const result = resolveAutoModel({ + phase: 'worker', + settings: defaultAutoModelSettings(), + providers: [provider('p1', ['claude-haiku-4', 'claude-sonnet-4', 'claude-opus-4'])], + getAvailableModels: (p) => p.models, + }); + expect(result.model.use_model).toBe('claude-sonnet-4'); + }); + + it('filters non-vision models when requireVision is set', () => { + const visionProvider = provider('p1', ['text-only', 'gpt-4o']); + visionProvider.model_capabilities = { + 'gpt-4o': { vision: true }, + 'text-only': { vision: false }, + }; + const result = resolveAutoModel({ + phase: 'worker', + settings: defaultAutoModelSettings(), + providers: [visionProvider], + getAvailableModels: (p) => p.models, + requireVision: true, + }); + expect(result.model.use_model).toBe('gpt-4o'); + }); }); diff --git a/tests/unit/renderer/conversation/AionrsSendBox.dom.test.tsx b/tests/unit/renderer/conversation/AionrsSendBox.dom.test.tsx index 1ce65fc78f6..2bc44ad0333 100644 --- a/tests/unit/renderer/conversation/AionrsSendBox.dom.test.tsx +++ b/tests/unit/renderer/conversation/AionrsSendBox.dom.test.tsx @@ -185,6 +185,9 @@ vi.mock('@/renderer/hooks/file/useOpenFileSelector', () => ({ onSlashBuiltinCommand: vi.fn(), }), })); +vi.mock('@/renderer/pages/conversation/Messages/hooks', () => ({ + useMessageList: () => [], +})); vi.mock('@/renderer/hooks/ui/useLatestRef', () => ({ useLatestRef: (value: T) => ({ current: value }), })); From ee841b2915965db57700f444b90129928a1a32b8 Mon Sep 17 00:00:00 2001 From: Jeff <36680501@qq.com> Date: Sat, 29 Aug 2026 11:52:27 +0800 Subject: [PATCH 7/7] test(conversation): mock useMessageList in sendBoxNoLeak Co-authored-by: Cursor --- tests/unit/renderer/conversation/sendBoxNoLeak.dom.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/renderer/conversation/sendBoxNoLeak.dom.test.tsx b/tests/unit/renderer/conversation/sendBoxNoLeak.dom.test.tsx index 73ad7e0764f..28a56332bb1 100644 --- a/tests/unit/renderer/conversation/sendBoxNoLeak.dom.test.tsx +++ b/tests/unit/renderer/conversation/sendBoxNoLeak.dom.test.tsx @@ -69,7 +69,10 @@ vi.mock('@/renderer/hooks/file/useOpenFileSelector', () => ({ useOpenFileSelector: () => ({ openFileSelector: vi.fn(), onSlashBuiltinCommand: vi.fn() }), })); vi.mock('@/renderer/hooks/ui/useLatestRef', () => ({ useLatestRef: (v: T) => ({ current: v }) })); -vi.mock('@/renderer/pages/conversation/Messages/hooks', () => ({ useAddOrUpdateMessage: () => vi.fn() })); +vi.mock('@/renderer/pages/conversation/Messages/hooks', () => ({ + useAddOrUpdateMessage: () => vi.fn(), + useMessageList: () => [], +})); vi.mock('@/renderer/pages/conversation/platforms/useConversationCommandQueue', () => ({ shouldEnqueueConversationCommand: () => false, useConversationCommandQueue: () => ({