diff --git a/docs/prds/conversations/auto-model.md b/docs/prds/conversations/auto-model.md new file mode 100644 index 00000000000..f59fc480df3 --- /dev/null +++ b/docs/prds/conversations/auto-model.md @@ -0,0 +1,75 @@ +# Auto planner/worker model routing(aionrs) + +> Issue: [#4143](https://github.com/iOfficeAI/AionUi/issues/4143) +> 范围:aionrs 会话模型选择、模型设置页槽位绑定、会话 `extra.auto_model`、发前 phase 路由 +> 非目标:ACP 平台、助手 `mode: auto`(「记住上次」)、把 Auto 设成默认模型 + +--- + +## 背景与目标 + +长时间运行(如 7×24)的 aionrs agent 若全程使用高价模型,会在低价值回合上浪费成本。业界常见做法是 **Planner / Worker** 分槽:规划用强模型,执行磨信用便宜模型。 + +| 阶段 | 能力 | 用户可感知收益 | +| --------------------- | ------------------------------------------------------------------------------------- | -------------------------------- | +| **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。 +- **Sentinel**:选择器内部 id `__aionui_auto__::auto`,**永不**作为后端 model 上报。 + +--- + +## Phase 1(已落地) + +见 F-AUTO-01 … F-AUTO-04:选择器 Auto、设置槽位、退出 Auto、失败提示。 + +--- + +## Phase 2 + +### (F-AUTO-05) 同 provider 热切换 [新增 · 依赖 AionCore] + +**Core**: + +- `PUT .../config-options/model` 对 aionrs 生效:调用 `AgentEngine::apply_config_update`,不 kill。 +- 回合中切换 → `pending_next_turn`,下一 turn 发送前应用。 +- `PATCH conversation.model` 在 **同一 `provider_id`** 时改为热切换;跨 provider 仍 kill+rebuild。 + +**验收**: + +- [ ] 同 provider 换模后会话 MCP/上下文保留,无需重建 agent。 +- [ ] 跨 provider 仍走重建路径。 + +### (F-AUTO-06) 发前 phase 路由 [新增 · AionUi] + +**流程**(`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}`。 + +**验收**: + +- [ ] 首条用户消息使用 planner 槽。 +- [ ] 常规续写使用 worker 槽。 +- [ ] 输入含 replan / 重新规划 等 → 回到 planner。 +- [ ] Core 尚无热切换时:setConfigOption 失败可降级为仅 persist(可能 rebuild)。 + +### (F-AUTO-07) Utility / 细粒度分类器 [后续] + +- Utility 槽与子代理默认路由。 +- OpenSquilla 式 turn classifier。 + +--- + +## 实现备注 + +- 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/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 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..d485d56be21 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,289 +482,295 @@ 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) => { - 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' - }`} +
+ {!data || data.length === 0 ? ( +
+ +

{t('settings.noConfiguredModels')}

+

+ {t('settings.needHelpConfigGuide')} + - - - {platform.name} - -

+ ) : ( + <> + + {(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/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..3828895dca9 100644 --- a/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx +++ b/packages/desktop/src/renderer/pages/conversation/components/ChatConversation.tsx @@ -152,9 +152,10 @@ 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 + // 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, @@ -162,14 +163,37 @@ 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] + [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 b688ee6234b..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,11 +69,17 @@ const AionrsModelSelector: React.FC<{ ); } - const { providers, getAvailableModels, handleSelectModel } = selection; + const { providers, getAvailableModels, handleSelectModel, handleSelectAuto, autoEnabled, autoPhase } = selection; const label = getModelDisplayLabel({ selected_value: current_model?.use_model, - selectedLabel: current_model?.use_model || '', + selectedLabel: autoEnabled + ? t('conversation.autoModel.pill', { + phase: autoPhase || 'worker', + model: current_model?.use_model || '', + defaultValue: `Auto · ${autoPhase || 'worker'}/${current_model?.use_model || ''}`, + }) + : current_model?.use_model || '', defaultModelLabel, fallbackLabel: t('conversation.welcome.selectModel'), }); @@ -85,7 +91,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 +119,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..3e746815800 100644 --- a/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx +++ b/packages/desktop/src/renderer/pages/conversation/platforms/aionrs/AionrsSendBox.tsx @@ -48,6 +48,13 @@ 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, + 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, useState } from 'react'; @@ -140,7 +147,9 @@ const AionrsSendBox: React.FC<{ })); const { t } = useTranslation(); const { checkAndUpdateTitle } = useAutoTitle(); - const { current_model } = modelSelection; + const { current_model, autoEnabled, syncAutoResolved, providers, getAvailableModels } = modelSelection; + const messageList = useMessageList(); + const messageListRef = useLatestRef(messageList); const teamPermission = useTeamPermission(); const propagateMode = teamPermission?.propagateMode; @@ -263,6 +272,27 @@ 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: conversationHasUserTurns(messageListRef.current), + requireVision: chatFileRefsRequireVision(files), + 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 }); @@ -311,14 +341,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, @@ -503,6 +538,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 +574,35 @@ 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', { + phase: modelSelection.autoPhase || 'worker', + model: modelSelection.current_model?.use_model || '', + defaultValue: `Auto · ${modelSelection.autoPhase || 'worker'}/${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..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,31 +6,56 @@ import type { IProvider, TProviderWithModel } from '@/common/config/storage'; import { useModelProviderList } from '@/renderer/hooks/agent/useModelProviderList'; +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'; 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; - onSelectModel: (provider: IProvider, modelName: string) => Promise; + 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); }, [initialModel?.id, initialModel?.use_model]); + useEffect(() => { + setAutoEnabled(initialAutoEnabled); + }, [initialAutoEnabled]); + + useEffect(() => { + setAutoPhase(initialAutoPhase); + }, [initialAutoPhase]); + const { providers: allProviders, getAvailableModels, formatModelLabel } = useModelProviderList(); // AionCore does not support Google Auth — filter it out @@ -45,14 +70,46 @@ 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); + setAutoPhase(undefined); } }, [onSelectModel] ); + 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(), + 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); + setAutoPhase('worker'); + } + } 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 syncAutoResolved = useCallback((model: TProviderWithModel, phase: AutoModelPhase) => { + setCurrentModel(model); + setAutoEnabled(true); + setAutoPhase(phase); + }, []); + const getDisplayModelName = useCallback( (modelName?: string) => { if (!modelName) return ''; @@ -65,9 +122,13 @@ export const useAionrsModelSelection = ({ return { current_model, + autoEnabled, + autoPhase, providers, getAvailableModels, handleSelectModel, + handleSelectAuto, + syncAutoResolved, 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..dff580a8eb6 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,34 @@ 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', { + phase: 'planner', + model: current_model?.use_model || '', + defaultValue: `Auto · planner/${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 +429,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..1643a65b1d6 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,15 @@ const GuidModelSelector: React.FC = ({ const geminiSelectedLabel = React.useMemo(() => { if (!current_model?.use_model) return ''; + if (autoEnabled) { + return t('conversation.autoModel.pill', { + phase: 'planner', + model: current_model.use_model, + defaultValue: `Auto · planner/${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 +126,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 +154,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 +171,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..96882a71fff 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: 'planner', + 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..cece1457923 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: 'planner', + last_resolved: { + provider_id: current_model.id, + model: current_model.use_model, + slot: 'planner', + }, + } + : { 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..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 @@ -782,5 +782,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Plan ausklappen", "collapse": "Plan einklappen" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (Planner/Worker-Routing)", + "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 57e44fa05f6..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 @@ -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": "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 27a1d73c482..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expand plan", "collapse": "Collapse plan" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (planner/worker routing)", + "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 f04bd0a7bb9..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 @@ -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": "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 b6e95b39492..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 @@ -782,5 +782,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expandir plan", "collapse": "Contraer plan" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (enrutamiento planner/worker)", + "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 93c61eef66a..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 @@ -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": "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 36b62dffc72..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "گسترش برنامه", "collapse": "جمع کردن برنامه" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (مسیریابی planner/worker)", + "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 902c4c34374..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 @@ -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": "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 296ac6354a1..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 @@ -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 · {{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 eb189152e60..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 @@ -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": "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 ff0e2f1ea4a..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "プランを展開", "collapse": "プランを折りたたむ" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(プランナー/ワーカー振り分け)", + "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 b3ecac6f67e..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 @@ -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": "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 ef5b3358240..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "계획 펼치기", "collapse": "계획 접기" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(플래너/워커 라우팅)", + "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 94d4f9d4639..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 @@ -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": "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 0df5598bdf8..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 @@ -787,5 +787,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Expandir plano", "collapse": "Recolher plano" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (roteamento planner/worker)", + "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 00147c20f9a..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 @@ -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": "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 08098f7a6dc..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 @@ -797,5 +797,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Развернуть план", "collapse": "Свернуть план" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (маршрутизация planner/worker)", + "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 c518eeecf3c..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 @@ -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": "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 48f013ff994..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 @@ -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 · {{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 a6cc97f8671..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 @@ -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": "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 30d1ce2c704..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 @@ -797,5 +797,11 @@ "progress": "{{completed}}/{{total}}", "expand": "Розгорнути план", "collapse": "Згорнути план" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto (маршрутизація planner/worker)", + "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 9dae7b4b74c..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 @@ -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": "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 ac049ff6d8d..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "展开计划", "collapse": "收起计划" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(规划/执行路由)", + "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 5470a235a1f..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 @@ -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 会在发送前按规划/执行阶段热切换模型,无需重建智能体。跨供应商仍会重建。" + } } 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..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 @@ -781,5 +781,11 @@ "progress": "{{completed}}/{{total}}", "expand": "展開計劃", "collapse": "收合計劃" + }, + "autoModel": { + "groupTitle": "Auto", + "optionLabel": "Auto(規劃/執行路由)", + "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 2d1ba23c7c4..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 @@ -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 會在發送前依規劃/執行階段熱切換模型,無需重建智能體。跨供應商仍會重建。" + } } 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..6f27daab7bc --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/applyAutoModelForTurn.ts @@ -0,0 +1,106 @@ +/** + * @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; + /** When true, only vision-capable models are considered. */ + requireVision?: boolean; + 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, + requireVision: input.requireVision, + }); + 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/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/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/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 new file mode 100644 index 00000000000..a27680da891 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/index.ts @@ -0,0 +1,33 @@ +/** + * @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 { 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'; +export { chatFileRefsRequireVision } from './vision'; +export { conversationHasUserTurns } from './conversationTurns'; 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..e9209d70af8 --- /dev/null +++ b/packages/desktop/src/renderer/utils/autoModel/tierHeuristics.ts @@ -0,0 +1,73 @@ +/** + * @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 }; + +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)); + +/** + * Frontier reasoning models — mutually exclusive tiers to avoid double-counting + * (e.g. `gemini-3-pro-preview` matching both `pro-preview` and `gemini.*pro`). + */ +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 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; +}; + +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; +}; + +/** + * 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') { + return base + scores.utility * 2 - scores.planner * 0.5; + } + if (preference === 'quality') { + return base + scores.planner * 1.5 - scores.utility * 0.5; + } + // balance — slight cost awareness without overriding seat fit + return base + scores.utility * 0.3; +}; 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/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/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/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/decideAutoModelPhase.test.ts b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts new file mode 100644 index 00000000000..d8e4c48a53d --- /dev/null +++ b/tests/unit/renderer/autoModel/decideAutoModelPhase.test.ts @@ -0,0 +1,33 @@ +/** + * @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'); + }); +}); 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 new file mode 100644 index 00000000000..fc22e0ffdee --- /dev/null +++ b/tests/unit/renderer/autoModel/resolveAutoModel.test.ts @@ -0,0 +1,135 @@ +/** + * @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 { preferenceWeight, 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); + }); + + 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', () => { + 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/); + }); + + 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 }), })); 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: () => ({