diff --git a/docs/prds/conversations/auto-model.md b/docs/prds/conversations/auto-model.md new file mode 100644 index 00000000000..b8cc9611369 --- /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/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/common/config/configKeys.ts b/packages/desktop/src/common/config/configKeys.ts index 48926e68cb3..9992c646985 100644 --- a/packages/desktop/src/common/config/configKeys.ts +++ b/packages/desktop/src/common/config/configKeys.ts @@ -31,6 +31,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 b69aa3dbdd8..102e8f7ba4c 100644 --- a/packages/desktop/src/common/config/storage.ts +++ b/packages/desktop/src/common/config/storage.ts @@ -81,6 +81,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 }; + }; + }; } /** @@ -478,6 +490,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..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 } 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 +30,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 +39,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 +119,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,26 +482,27 @@ const ModelModalContent: React.FC = () => { {/* Content Area */} - {!data || data.length === 0 ? ( -
- -

{t('settings.noConfiguredModels')}

-

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

-
- ) : ( -
- {(data || []).map((platform: IProvider) => { +
+ + {!data || data.length === 0 ? ( +
+ +

{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; return ( @@ -682,9 +763,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..b3f35e9f875 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,28 @@ 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, + // merge_extra patch — same cast pattern as useAionrsMessage token usage. + extra: { + auto_model: autoEnabled + ? { + enabled: true, + phase: 'worker', + last_resolved: { + provider_id: selected.id, + model: selected.use_model, + slot: 'worker', + }, + } + : { enabled: false }, + } as TChatConversation['extra'], + }, + merge_extra: true, + }); return Boolean(ok); }, [conversation.id, runtimeView] @@ -170,6 +191,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..9f4a828493e 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,45 @@ 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 +59,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 +101,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 1b5c155a7f5..7343e8b73ed 100644 --- a/packages/desktop/src/renderer/pages/guid/GuidPage.tsx +++ b/packages/desktop/src/renderer/pages/guid/GuidPage.tsx @@ -264,6 +264,7 @@ const GuidPage: React.FC = () => { selectedThoughtLevelValue: agentSelection.selectedThoughtLevelValue, currentAcpCachedModelInfo: agentSelection.currentAcpCachedModelInfo, current_model: modelSelection.current_model, + autoEnabled: modelSelection.autoEnabled, guidDisabledBuiltinSkills, guidEnabledSkills, @@ -596,6 +597,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} @@ -623,6 +626,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 e09def76894..41a04d98dba 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts @@ -37,6 +37,8 @@ export type GuidSendDeps = { selectedThoughtLevelValue?: string; currentAcpCachedModelInfo: AcpModelInfo | null; current_model: TProviderWithModel | undefined; + /** Sticky Auto selection for aionrs (issue #4143). */ + autoEnabled?: boolean; guidDisabledBuiltinSkills: string[] | undefined; guidEnabledSkills: string[] | undefined; @@ -85,6 +87,7 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { selectedThoughtLevelValue, currentAcpCachedModelInfo, current_model, + autoEnabled = false, guidDisabledBuiltinSkills, guidEnabledSkills, assistantDefaultSkillIds, @@ -186,6 +189,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 }, }, }); @@ -288,6 +302,7 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { selectedThoughtLevelValue, currentAcpCachedModelInfo, 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 3be51fe0872..41a2ba15c9b 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' @@ -1743,6 +1747,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 e4cd4411a00..4317a2eb98c 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 @@ -1406,5 +1406,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 df6540e71ba..0470a89e2c6 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 @@ -1406,5 +1406,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 8814b6cf0c6..ca969bff00a 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 @@ -1406,5 +1406,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 4422006818e..f2bdb070bb5 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 @@ -1406,5 +1406,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 ba8f9031bf5..5ad8c464de4 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 @@ -1406,5 +1406,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 9cf555245b9..607a16aa847 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 @@ -1408,5 +1408,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 a159ea187b8..71e6e590ee3 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 @@ -1408,5 +1408,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 6f6b030a448..d262a5e8378 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 @@ -1409,5 +1409,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 e5d0a1040f3..0b585922946 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 @@ -1420,5 +1420,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 3ec10c95da9..8246bfb133d 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 @@ -1406,5 +1406,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 dcac45f613c..3121c3f992b 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 @@ -1421,5 +1421,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 23b27ff5638..3fb1a3907a0 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 @@ -1407,5 +1407,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 7ffb810b673..024793ad359 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 @@ -1408,5 +1408,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..9440e646ee2 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -0,0 +1,27 @@ +/** + * @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..fd84740781d --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/resolveAutoModel.ts @@ -0,0 +1,104 @@ +/** + * @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..b51ba5badb6 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts @@ -0,0 +1,67 @@ +/** + * @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..d37bc1a1efc --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/types.ts @@ -0,0 +1,55 @@ +/** + * @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/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 new file mode 100644 index 00000000000..1e52dde467b --- /dev/null +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -0,0 +1,85 @@ +/** + * @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/); + }); +});