diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md index 8fff4fc5..13201ac5 100644 --- a/docs/architecture/settings-integration-configuration-model.md +++ b/docs/architecture/settings-integration-configuration-model.md @@ -29,7 +29,7 @@ flowchart TD subgraph APPSTATE["App-side derived state"] F["refreshSingleAgentCapabilitiesRuntimeInternal()"] - G["bridgeProviderCatalogInternal"] + G["bridgeAgentProviderCatalogInternal
bridgeGatewayProviderCatalogInternal
bridgeAvailableExecutionTargetsInternal"] H["singleAgentCapabilitiesByProviderInternal"] I["refreshAcpCapabilitiesRuntimeInternal()"] J["GatewayAcpCapabilities"] @@ -78,7 +78,7 @@ flowchart TD ## Notes -- `providerCatalog` 只负责 assistant provider picker;不会因为线程里保存过 `providerId` 就被 app 反向重建 +- provider picker 的真源只来自 bridge 返回的 target-scoped catalog;不会因为线程里保存过 `providerId` 就被 app 反向重建 - gateway runtime 可见性来自 bridge capability snapshot 与 `xworkmate.gateway.*` 返回,不来自旧设置页枚举 - bridge 若返回额外 capability flag,这些 flag 只属于合同元数据,不会自动生成新的 settings tab 或 module page - production provider / gateway 选择继续由 bridge 拥有,app 只保留消费与展示 diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md index 414f4e1e..74619ddd 100644 --- a/docs/architecture/task-control-plane-unification.md +++ b/docs/architecture/task-control-plane-unification.md @@ -76,12 +76,14 @@ flowchart TD ### Provider Truth -- `acp.capabilities.providerCatalog` 是 assistant provider picker 的唯一上游真源 +- `acp.capabilities` 是任务对话模式与 provider picker 的唯一上游真源 - 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog - provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve` -- 任务对话模式的 provider 菜单按 execution target 分流: - - `agent` 只展示 bridge-owned provider catalog,即 `codex / opencode / gemini` - - `gateway` 只展示 canonical gateway provider,即 `OpenClaw` +- bridge 返回 `availableExecutionTargets` 与 target-scoped provider catalog;app 只做目标切换与展示,不做静态拆分或 canonical 单项硬编码 +- app 只负责: + - 展示 `agent` / `gateway` 目标切换 + - 请求 bridge contract + - 按 bridge 返回结果渲染 provider 菜单与默认项 - 这里不保留旧的 provider matrix、preset fallback 或双真源选择路径 ### Gateway Truth diff --git a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md index d644048d..39f86e93 100644 --- a/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md +++ b/docs/architecture/xworkmate-core-module-inventory-2026-04-13.md @@ -149,7 +149,8 @@ Status: `Active` 当前 Assistant 事实: - provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth -- 任务对话模式按 execution target 分流:`智能体` 只提供 `codex / opencode / gemini`,`Gateway` 只提供 `OpenClaw` +- 任务对话模式只保留两类一级目标:`agent` / `gateway` +- 每个目标下的 provider 菜单都只消费 `xworkmate-bridge` 返回的动态 catalog;app 不维护 `codex / opencode / gemini / openclaw` 这类本地固定列表 - task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage` - skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage` - assistant focus 只保留仍有真实落点的 `settings / language / theme` diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index eb486aef..172df643 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -120,6 +120,8 @@ class AppController extends ChangeNotifier { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, List? initialBridgeProviderCatalog, + List? initialGatewayProviderCatalog, + List? initialAvailableExecutionTargets, SkillDirectoryAccessService? skillDirectoryAccessService, AccountRuntimeClient Function(String baseUrl)? accountClientFactory, Map? environmentOverride, @@ -225,9 +227,15 @@ class AppController extends ChangeNotifier { endpointResolver: resolveGatewayAcpEndpointInternal, ), ); - bridgeProviderCatalogInternal = normalizeBridgeOwnedSingleAgentProviderList( + bridgeAgentProviderCatalogInternal = normalizeSingleAgentProviderList( initialBridgeProviderCatalog ?? const [], ); + bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList( + initialGatewayProviderCatalog ?? const [], + ); + bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets( + initialAvailableExecutionTargets ?? const [], + ); attachChildListenersInternal(); unawaited(initializeInternal()); @@ -290,8 +298,12 @@ class AppController extends ChangeNotifier { GatewayAcpClient get gatewayAcpClientForTest => gatewayAcpClientInternal; - List bridgeProviderCatalogInternal = + List bridgeAgentProviderCatalogInternal = const []; + List bridgeGatewayProviderCatalogInternal = + const []; + List bridgeAvailableExecutionTargetsInternal = + const []; final Map> assistantThreadMessagesInternal = >{}; late final DesktopTaskThreadRepository taskThreadRepositoryInternal = @@ -546,19 +558,30 @@ class AppController extends ChangeNotifier { ); List get bridgeProviderCatalog => - normalizeSingleAgentProviderList(bridgeProviderCatalogInternal); + normalizeSingleAgentProviderList([ + ...bridgeAgentProviderCatalogInternal, + ...bridgeGatewayProviderCatalogInternal, + ]); List get assistantProviderCatalog => - normalizeBridgeOwnedSingleAgentProviderList( - bridgeProviderCatalogInternal, - ); + normalizeSingleAgentProviderList(bridgeAgentProviderCatalogInternal); + + List get gatewayProviderCatalog => + normalizeSingleAgentProviderList(bridgeGatewayProviderCatalogInternal); + + List get bridgeAvailableExecutionTargets => + compactAssistantExecutionTargets(bridgeAvailableExecutionTargetsInternal); List get assistantProviderCatalogForDisplay { - final liveCatalog = assistantProviderCatalog; - if (liveCatalog.isNotEmpty) { - return liveCatalog; - } - return kBridgeOwnedSingleAgentProviders; + return assistantProviderCatalog; + } + + List providerCatalogForExecutionTarget( + AssistantExecutionTarget executionTarget, + ) { + return executionTarget.isGateway + ? gatewayProviderCatalog + : assistantProviderCatalogForDisplay; } SingleAgentProvider? bridgeProviderForId(String providerId) { @@ -574,11 +597,14 @@ class AppController extends ChangeNotifier { return null; } - SingleAgentProvider resolveAssistantProvider(String? providerId) { + SingleAgentProvider resolveProviderForExecutionTarget( + String? providerId, { + required AssistantExecutionTarget executionTarget, + }) { final normalizedProviderId = normalizeSingleAgentProviderId( providerId ?? '', ); - final catalog = assistantProviderCatalogForDisplay; + final catalog = providerCatalogForExecutionTarget(executionTarget); if (normalizedProviderId.isNotEmpty) { for (final provider in catalog) { if (provider.providerId == normalizedProviderId) { @@ -589,9 +615,23 @@ class AppController extends ChangeNotifier { if (catalog.isNotEmpty) { return catalog.first; } + if (normalizedProviderId.isNotEmpty) { + return SingleAgentProvider.fromJsonValue( + normalizedProviderId, + supportedTargets: [executionTarget], + enabled: false, + ); + } return SingleAgentProvider.unspecified; } + SingleAgentProvider resolveAssistantProvider(String? providerId) { + return resolveProviderForExecutionTarget( + providerId, + executionTarget: AssistantExecutionTarget.agent, + ); + } + SingleAgentProvider assistantProviderForSession(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -600,10 +640,10 @@ class AppController extends ChangeNotifier { final executionTarget = assistantExecutionTargetForSession( normalizedSessionKey, ); - if (executionTarget.isGateway) { - return SingleAgentProvider.openclaw; - } - return resolveAssistantProvider(thread?.executionBinding.providerId); + return resolveProviderForExecutionTarget( + thread?.executionBinding.providerId, + executionTarget: executionTarget, + ); } UiFeatureManifest loadRepoUiFeatureManifestSyncInternal() { @@ -618,7 +658,16 @@ class AppController extends ChangeNotifier { List visibleAssistantExecutionTargets( Iterable supportedTargets, - ) => compactAssistantExecutionTargets(supportedTargets); + ) { + final visible = compactAssistantExecutionTargets(supportedTargets); + final bridgeVisible = bridgeAvailableExecutionTargets; + if (bridgeVisible.isEmpty) { + return visible; + } + return visible + .where((item) => bridgeVisible.contains(item)) + .toList(growable: false); + } List get aiGatewayConversationModelChoices { final availableModels = diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 485f159b..3718d197 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -59,9 +59,13 @@ Future refreshAcpCapabilitiesRuntimeInternal( // Keep mount refresh resilient when ACP is temporarily unavailable. } if (capabilities != null) { - controller.bridgeProviderCatalogInternal = - normalizeBridgeOwnedSingleAgentProviderList( - capabilities.providerCatalog, + controller.bridgeAgentProviderCatalogInternal = + normalizeSingleAgentProviderList(capabilities.providerCatalog); + controller.bridgeGatewayProviderCatalogInternal = + normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog); + controller.bridgeAvailableExecutionTargetsInternal = + compactAssistantExecutionTargets( + capabilities.availableExecutionTargets, ); } if (persistMountTargets && !controller.disposedInternal) { @@ -90,12 +94,21 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal( try { final capabilities = await controller.gatewayAcpClientInternal .loadCapabilities(forceRefresh: forceRefresh); - controller.bridgeProviderCatalogInternal = - normalizeBridgeOwnedSingleAgentProviderList( - capabilities.providerCatalog, + controller.bridgeAgentProviderCatalogInternal = + normalizeSingleAgentProviderList(capabilities.providerCatalog); + controller.bridgeGatewayProviderCatalogInternal = + normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog); + controller.bridgeAvailableExecutionTargetsInternal = + compactAssistantExecutionTargets( + capabilities.availableExecutionTargets, ); } catch (_) { - controller.bridgeProviderCatalogInternal = const []; + controller.bridgeAgentProviderCatalogInternal = + const []; + controller.bridgeGatewayProviderCatalogInternal = + const []; + controller.bridgeAvailableExecutionTargetsInternal = + const []; } if (!controller.disposedInternal) { controller.notifyListeners(); @@ -109,16 +122,19 @@ mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal( GatewayAcpCapabilities capabilities, ) { final source = current.isEmpty ? ManagedMountTargetState.defaults() : current; - final providers = capabilities.providerCatalog + final agentProviders = capabilities.providerCatalog + .map((item) => item.providerId) + .toSet(); + final gatewayProviders = capabilities.gatewayProviderCatalog .map((item) => item.providerId) .toSet(); return source .map((item) { final available = switch (item.targetId) { - 'codex' => providers.contains('codex'), - 'opencode' => providers.contains('opencode'), - 'gemini' => providers.contains('gemini'), - 'openclaw' => capabilities.multiAgent || capabilities.singleAgent, + 'codex' => agentProviders.contains('codex'), + 'opencode' => agentProviders.contains('opencode'), + 'gemini' => agentProviders.contains('gemini'), + 'openclaw' => gatewayProviders.contains('openclaw'), _ => false, }; return item.copyWith( diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 4ab8d94f..c7dc4e3c 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -300,9 +300,10 @@ extension AppControllerDesktopSkillPermissions on AppController { existing?.contextState.latestResolvedProviderId ?? '', ); - final nextProvider = nextProviderId.isEmpty - ? SingleAgentProvider.unspecified - : resolveAssistantProvider(nextProviderId); + final nextProvider = resolveProviderForExecutionTarget( + nextProviderId, + executionTarget: nextExecutionTarget, + ); final nextProviderSource = singleAgentProviderSource ?? existing?.executionBinding.providerSource ?? diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 74add700..7c0e470f 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -222,9 +222,10 @@ extension AppControllerDesktopThreadBinding on AppController { final persistedProviderId = normalizeSingleAgentProviderId( existingBinding?.providerId ?? '', ); - final selectedProvider = persistedProviderId.isEmpty - ? SingleAgentProvider.unspecified - : resolveAssistantProvider(persistedProviderId); + final selectedProvider = resolveProviderForExecutionTarget( + persistedProviderId, + executionTarget: executionTarget, + ); return (existingBinding ?? ExecutionBinding( executionMode: threadExecutionModeFromAssistantExecutionTarget( diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 81a9e3eb..e5d617ae 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -458,16 +458,7 @@ AssistantExecutionTarget resolveAssistantExecutionTargetFromRecordsForTest( final record = primaryRecord ?? fallbackRecord; return record == null ? AssistantExecutionTarget.agent - : (() { - final resolved = assistantExecutionTargetFromExecutionMode( - record.executionBinding.executionMode, - ); - if (resolved.isGateway && - isBridgeOwnedSingleAgentProviderId( - record.executionBinding.providerId, - )) { - return AssistantExecutionTarget.agent; - } - return resolved; - })(); + : assistantExecutionTargetFromExecutionMode( + record.executionBinding.executionMode, + ); } diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 7173547f..5c6436ed 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -688,15 +688,11 @@ extension AppControllerDesktopThreadStorage on AppController { final recordProviderId = normalizeSingleAgentProviderId( record.executionBinding.providerId, ); - final recordProvider = recordProviderId.isEmpty - ? SingleAgentProvider.unspecified - : resolveAssistantProvider(recordProviderId); - final normalizedExecutionTarget = - recordExecutionTarget.isGateway && - recordProviderId.isNotEmpty && - isBridgeOwnedSingleAgentProviderId(recordProviderId) - ? AssistantExecutionTarget.agent - : recordExecutionTarget; + final normalizedExecutionTarget = recordExecutionTarget; + final recordProvider = resolveProviderForExecutionTarget( + recordProviderId, + executionTarget: normalizedExecutionTarget, + ); final workspaceBinding = record.workspaceBinding.copyWith( workspaceId: sessionKey, displayPath: record.workspaceKind == WorkspaceKind.localFs diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index e7a02d89..ae4c2522 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -54,14 +54,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController { sessionsControllerInternal.currentSessionKey, ); final shouldRefreshAgentProviders = - resolvedTarget.isAgent && assistantProviderCatalog.isEmpty; + providerCatalogForExecutionTarget(resolvedTarget).isEmpty; if (shouldRefreshAgentProviders) { try { await refreshSingleAgentCapabilitiesInternal(forceRefresh: true); } catch (_) { // Keep target selection interactive even when a just-in-time - // capabilities refresh fails. The dialog still shows the canonical - // single-agent providers while the live catalog catches up. + // capabilities refresh fails. The dialog stays interactive while the + // live catalog catches up from bridge capabilities. } if (currentTarget == resolvedTarget && settings.assistantExecutionTarget == resolvedTarget) { @@ -99,6 +99,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController { sessionsControllerInternal.currentSessionKey, executionTarget: resolvedTarget, executionTargetSource: ThreadSelectionSource.explicit, + singleAgentProvider: resolveProviderForExecutionTarget( + taskThreadForSessionInternal( + sessionsControllerInternal.currentSessionKey, + )?.executionBinding.providerId, + executionTarget: resolvedTarget, + ), + singleAgentProviderSource: ThreadSelectionSource.explicit, gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget), latestResolvedRuntimeModel: '', latestResolvedProviderId: '', @@ -215,6 +222,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController { ); upsertTaskThreadInternal( normalizedSessionKey, + singleAgentProvider: resolveProviderForExecutionTarget( + taskThreadForSessionInternal(normalizedSessionKey) + ?.executionBinding + .providerId, + executionTarget: resolvedTarget, + ), + singleAgentProviderSource: ThreadSelectionSource.explicit, latestResolvedRuntimeModel: '', latestResolvedProviderId: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), diff --git a/lib/features/assistant/assistant_page_composer_support.dart b/lib/features/assistant/assistant_page_composer_support.dart index b8d56878..caea08bf 100644 --- a/lib/features/assistant/assistant_page_composer_support.dart +++ b/lib/features/assistant/assistant_page_composer_support.dart @@ -217,6 +217,7 @@ class SingleAgentProviderBadgeInternal extends StatelessWidget { @override Widget build(BuildContext context) { final palette = context.palette; + final logoEmoji = provider.logoEmoji.trim(); final candidate = provider.badge.trim().isEmpty ? provider.label : provider.badge; @@ -235,13 +236,13 @@ class SingleAgentProviderBadgeInternal extends StatelessWidget { border: Border.all(color: palette.strokeSoft), ), child: Text( - display, + logoEmoji.isEmpty ? display : logoEmoji, maxLines: 1, overflow: TextOverflow.clip, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: palette.textMuted, fontWeight: FontWeight.w700, - fontSize: 9, + fontSize: logoEmoji.isEmpty ? 9 : 11, height: 1, ), ), diff --git a/lib/features/assistant/assistant_page_task_dialog_controls.dart b/lib/features/assistant/assistant_page_task_dialog_controls.dart index a05109f7..31861713 100644 --- a/lib/features/assistant/assistant_page_task_dialog_controls.dart +++ b/lib/features/assistant/assistant_page_task_dialog_controls.dart @@ -23,16 +23,22 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget { final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); - final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( + final supportedExecutionTargets = compactAssistantExecutionTargets( uiFeatures.availableExecutionTargets, ); - if (visibleExecutionTargets.isEmpty) { + if (supportedExecutionTargets.isEmpty) { return const SizedBox.shrink(); } + final visibleExecutionTargets = controller.visibleAssistantExecutionTargets( + supportedExecutionTargets, + ); + final resolutionTargets = visibleExecutionTargets.isNotEmpty + ? visibleExecutionTargets + : supportedExecutionTargets; final currentExecutionTarget = resolveAssistantExecutionTargetFromVisibleTargets( - visibleExecutionTargets, + resolutionTargets, currentTarget: controller.assistantExecutionTarget, ); final executionTarget = collapseAssistantExecutionTargetForDisplay( @@ -51,17 +57,17 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget { _TaskDialogExecutionTargetMenuButtonInternal( controller: controller, executionTarget: executionTarget, + supportedExecutionTargets: supportedExecutionTargets, visibleExecutionTargets: visibleExecutionTargets, ), - if (providerMenuProviders.isNotEmpty) - _TaskDialogProviderMenuButtonInternal( - controller: controller, - executionTarget: executionTarget, - selectedProvider: controller.assistantProviderForSession( - controller.currentSessionKey, - ), - providers: providerMenuProviders, + _TaskDialogProviderMenuButtonInternal( + controller: controller, + executionTarget: executionTarget, + selectedProvider: controller.assistantProviderForSession( + controller.currentSessionKey, ), + providers: providerMenuProviders, + ), ], ); } @@ -71,30 +77,24 @@ List _taskDialogProviderCatalogForTarget({ required AppController controller, required AssistantExecutionTarget executionTarget, }) { - if (executionTarget.isGateway) { - return [ - controller.assistantProviderForSession(controller.currentSessionKey), - ]; - } - return controller.assistantProviderCatalogForDisplay; + return controller.providerCatalogForExecutionTarget(executionTarget); } class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { const _TaskDialogExecutionTargetMenuButtonInternal({ required this.controller, required this.executionTarget, + required this.supportedExecutionTargets, required this.visibleExecutionTargets, }); final AppController controller; final AssistantExecutionTarget executionTarget; + final List supportedExecutionTargets; final List visibleExecutionTargets; @override Widget build(BuildContext context) { - final compactExecutionTargets = compactAssistantExecutionTargets( - visibleExecutionTargets, - ); final palette = context.palette; final selectedLabel = executionTarget.label; @@ -104,21 +104,25 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { onSelected: (value) { unawaited(_handleExecutionTargetSelected(value)); }, - itemBuilder: (context) => compactExecutionTargets + itemBuilder: (context) => supportedExecutionTargets .map( - (value) => PopupMenuItem( - value: value, - key: Key('assistant-execution-target-menu-item-${value.name}'), - child: Row( - children: [ - Icon(value.icon, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(value.label)), - if (value == executionTarget) - const Icon(Icons.check_rounded, size: 18), - ], - ), - ), + (value) { + final enabled = visibleExecutionTargets.contains(value); + return PopupMenuItem( + value: value, + enabled: enabled, + key: Key('assistant-execution-target-menu-item-${value.name}'), + child: Row( + children: [ + Icon(value.icon, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(value.label)), + if (value == executionTarget) + const Icon(Icons.check_rounded, size: 18), + ], + ), + ); + }, ) .toList(growable: false), child: _TaskDialogSelectorChipInternal( @@ -156,11 +160,13 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget { @override Widget build(BuildContext context) { final displayProvider = selectedProvider.isUnspecified - ? _fallbackDisplayProvider() + ? _fallbackDisplayProvider(context) : selectedProvider; + final isEnabled = providers.isNotEmpty; return PopupMenuButton( key: const Key('assistant-provider-button'), + enabled: isEnabled, tooltip: appText('智能体 Provider', 'Agent Provider'), onSelected: (provider) { unawaited(_handleProviderSelected(provider)); @@ -198,18 +204,21 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget { ); } - SingleAgentProvider _fallbackDisplayProvider() { - if (executionTarget.isGateway) { - return SingleAgentProvider.openclaw; - } + SingleAgentProvider _fallbackDisplayProvider(BuildContext context) { if (providers.isNotEmpty) { return providers.first; } - return SingleAgentProvider.codex; + return SingleAgentProvider( + providerId: '', + label: appText('未提供', 'Unavailable'), + badge: '?', + supportedTargets: [executionTarget], + enabled: false, + ); } Future _handleProviderSelected(SingleAgentProvider provider) async { - if (executionTarget.isGateway) { + if (executionTarget.isGateway || providers.isEmpty) { return; } await controller.setAssistantSingleAgentProvider(provider); diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index 6577c9b9..90fbdbeb 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -34,9 +34,11 @@ class ExternalCodeAgentAcpDesktopTransport final caps = _castMap(result['capabilities']); final providerCatalog = _parseProviderCatalog( result['providerCatalog'] ?? caps['providerCatalog'], + defaultTarget: AssistantExecutionTarget.agent, ); - final gatewayProviders = _castMapList( + final gatewayProviders = _parseProviderCatalog( result['gatewayProviders'] ?? caps['gatewayProviders'], + defaultTarget: AssistantExecutionTarget.gateway, ); return ExternalCodeAgentAcpCapabilities( singleAgent: @@ -47,6 +49,14 @@ class ExternalCodeAgentAcpDesktopTransport _boolValue(result['multiAgent']) ?? _boolValue(caps['multi_agent']) ?? true, + availableExecutionTargets: _parseAvailableExecutionTargets( + result['availableExecutionTargets'] ?? caps['availableExecutionTargets'], + singleAgent: + _boolValue(result['singleAgent']) ?? + _boolValue(caps['single_agent']) ?? + providerCatalog.isNotEmpty, + gatewayProviders: gatewayProviders, + ), providerCatalog: providerCatalog, gatewayProviders: gatewayProviders, raw: result, @@ -166,10 +176,6 @@ class ExternalCodeAgentAcpDesktopTransport return const []; } - List> _castMapList(Object? raw) { - return _asList(raw).map(_castMap).toList(growable: false); - } - bool? _boolValue(Object? raw) { if (raw is bool) { return raw; @@ -190,7 +196,10 @@ class ExternalCodeAgentAcpDesktopTransport return null; } - List _parseProviderCatalog(Object? raw) { + List _parseProviderCatalog( + Object? raw, { + required AssistantExecutionTarget defaultTarget, + }) { final providers = []; for (final item in _asList(raw)) { final entry = _castMap(item); @@ -199,14 +208,85 @@ class ExternalCodeAgentAcpDesktopTransport continue; } final label = entry['label']?.toString().trim(); + final providerDisplay = _castMap(entry['providerDisplay']); + final targets = _parseProviderTargets( + entry['targets'] ?? entry['executionTarget'], + defaultTarget: defaultTarget, + ); final provider = SingleAgentProviderCopy.fromJsonValue( providerId, label: label?.isNotEmpty == true ? label : null, + badge: entry['badge']?.toString().trim().isNotEmpty == true + ? entry['badge']?.toString().trim() + : providerDisplay['badge']?.toString().trim(), + logoEmoji: entry['logoEmoji']?.toString().trim().isNotEmpty == true + ? entry['logoEmoji']?.toString().trim() + : providerDisplay['logoEmoji']?.toString().trim(), + supportedTargets: targets, + enabled: _boolValue(entry['enabled']) ?? true, + unavailableReason: + entry['unavailableReason']?.toString().trim().isNotEmpty == true + ? entry['unavailableReason']?.toString().trim() + : '', ); if (!provider.isUnspecified) { providers.add(provider); } } - return normalizeBridgeOwnedSingleAgentProviderList(providers); + return normalizeSingleAgentProviderList(providers); + } + + List _parseAvailableExecutionTargets( + Object? raw, { + required bool singleAgent, + required List gatewayProviders, + }) { + final parsed = []; + for (final item in _asList(raw)) { + final normalized = item?.toString().trim().toLowerCase() ?? ''; + if (normalized == 'agent' || normalized == 'single-agent') { + if (!parsed.contains(AssistantExecutionTarget.agent)) { + parsed.add(AssistantExecutionTarget.agent); + } + } else if (normalized == 'gateway') { + if (!parsed.contains(AssistantExecutionTarget.gateway)) { + parsed.add(AssistantExecutionTarget.gateway); + } + } + } + if (parsed.isNotEmpty) { + return parsed; + } + if (singleAgent) { + parsed.add(AssistantExecutionTarget.agent); + } + if (gatewayProviders.isNotEmpty) { + parsed.add(AssistantExecutionTarget.gateway); + } + return parsed; + } + + List _parseProviderTargets( + Object? raw, { + required AssistantExecutionTarget defaultTarget, + }) { + final parsed = []; + final items = raw is List ? raw : [raw]; + for (final item in items) { + final normalized = item?.toString().trim().toLowerCase() ?? ''; + if (normalized == 'agent' || normalized == 'single-agent') { + if (!parsed.contains(AssistantExecutionTarget.agent)) { + parsed.add(AssistantExecutionTarget.agent); + } + } else if (normalized == 'gateway') { + if (!parsed.contains(AssistantExecutionTarget.gateway)) { + parsed.add(AssistantExecutionTarget.gateway); + } + } + } + if (parsed.isNotEmpty) { + return parsed; + } + return [defaultTarget]; } } diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 1c36f4d8..21b4e952 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -20,7 +20,9 @@ class GatewayAcpCapabilities { const GatewayAcpCapabilities({ required this.singleAgent, required this.multiAgent, + required this.availableExecutionTargets, required this.providerCatalog, + required this.gatewayProviderCatalog, required this.raw, this.diagnostics = const {}, }); @@ -28,13 +30,17 @@ class GatewayAcpCapabilities { const GatewayAcpCapabilities.empty() : singleAgent = false, multiAgent = false, + availableExecutionTargets = const [], providerCatalog = const [], + gatewayProviderCatalog = const [], raw = const {}, diagnostics = const {}; final bool singleAgent; final bool multiAgent; + final List availableExecutionTargets; final List providerCatalog; + final List gatewayProviderCatalog; final Map raw; final Map diagnostics; } @@ -121,6 +127,11 @@ class GatewayAcpClient { final caps = asMap(result['capabilities']); final providerCatalog = _parseProviderCatalog( result['providerCatalog'] ?? caps['providerCatalog'], + defaultTarget: AssistantExecutionTarget.agent, + ); + final gatewayProviderCatalog = _parseProviderCatalog( + result['gatewayProviders'] ?? caps['gatewayProviders'], + defaultTarget: AssistantExecutionTarget.gateway, ); final singleAgent = boolValue(result['singleAgent']) ?? @@ -133,7 +144,13 @@ class GatewayAcpClient { _cachedCapabilities = GatewayAcpCapabilities( singleAgent: singleAgent, multiAgent: multiAgent, + availableExecutionTargets: _parseAvailableExecutionTargets( + result['availableExecutionTargets'] ?? caps['availableExecutionTargets'], + singleAgent: singleAgent, + gatewayProviderCatalog: gatewayProviderCatalog, + ), providerCatalog: providerCatalog, + gatewayProviderCatalog: gatewayProviderCatalog, raw: result, diagnostics: asMap(response['_xworkmateDiagnostics']), ); @@ -141,7 +158,10 @@ class GatewayAcpClient { return _cachedCapabilities; } - List _parseProviderCatalog(Object? raw) { + List _parseProviderCatalog( + Object? raw, { + required AssistantExecutionTarget defaultTarget, + }) { final providers = []; for (final item in asList(raw)) { final entry = asMap(item); @@ -150,15 +170,86 @@ class GatewayAcpClient { continue; } final label = entry['label']?.toString().trim(); + final providerDisplay = asMap(entry['providerDisplay']); + final targets = _parseProviderTargets( + entry['targets'] ?? entry['executionTarget'], + defaultTarget: defaultTarget, + ); final provider = SingleAgentProviderCopy.fromJsonValue( providerId, label: label?.isNotEmpty == true ? label : null, + badge: entry['badge']?.toString().trim().isNotEmpty == true + ? entry['badge']?.toString().trim() + : providerDisplay['badge']?.toString().trim(), + logoEmoji: entry['logoEmoji']?.toString().trim().isNotEmpty == true + ? entry['logoEmoji']?.toString().trim() + : providerDisplay['logoEmoji']?.toString().trim(), + supportedTargets: targets, + enabled: boolValue(entry['enabled']) ?? true, + unavailableReason: + entry['unavailableReason']?.toString().trim().isNotEmpty == true + ? entry['unavailableReason']?.toString().trim() + : '', ); if (!provider.isUnspecified) { providers.add(provider); } } - return normalizeBridgeOwnedSingleAgentProviderList(providers); + return normalizeSingleAgentProviderList(providers); + } + + List _parseAvailableExecutionTargets( + Object? raw, { + required bool singleAgent, + required List gatewayProviderCatalog, + }) { + final parsed = []; + for (final item in asList(raw)) { + final normalized = item?.toString().trim().toLowerCase() ?? ''; + if (normalized == 'agent' || normalized == 'single-agent') { + if (!parsed.contains(AssistantExecutionTarget.agent)) { + parsed.add(AssistantExecutionTarget.agent); + } + } else if (normalized == 'gateway') { + if (!parsed.contains(AssistantExecutionTarget.gateway)) { + parsed.add(AssistantExecutionTarget.gateway); + } + } + } + if (parsed.isNotEmpty) { + return parsed; + } + if (singleAgent) { + parsed.add(AssistantExecutionTarget.agent); + } + if (gatewayProviderCatalog.isNotEmpty) { + parsed.add(AssistantExecutionTarget.gateway); + } + return parsed; + } + + List _parseProviderTargets( + Object? raw, { + required AssistantExecutionTarget defaultTarget, + }) { + final parsed = []; + final items = raw is List ? raw : [raw]; + for (final item in items) { + final normalized = item?.toString().trim().toLowerCase() ?? ''; + if (normalized == 'agent' || normalized == 'single-agent') { + if (!parsed.contains(AssistantExecutionTarget.agent)) { + parsed.add(AssistantExecutionTarget.agent); + } + } else if (normalized == 'gateway') { + if (!parsed.contains(AssistantExecutionTarget.gateway)) { + parsed.add(AssistantExecutionTarget.gateway); + } + } + } + if (parsed.isNotEmpty) { + return parsed; + } + return [defaultTarget]; } Stream runMultiAgent( diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index 4edcde17..89139e62 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -8,6 +8,7 @@ class ExternalCodeAgentAcpCapabilities { const ExternalCodeAgentAcpCapabilities({ required this.singleAgent, required this.multiAgent, + required this.availableExecutionTargets, required this.providerCatalog, required this.gatewayProviders, required this.raw, @@ -16,14 +17,16 @@ class ExternalCodeAgentAcpCapabilities { const ExternalCodeAgentAcpCapabilities.empty() : singleAgent = false, multiAgent = false, + availableExecutionTargets = const [], providerCatalog = const [], - gatewayProviders = const >[], + gatewayProviders = const [], raw = const {}; final bool singleAgent; final bool multiAgent; + final List availableExecutionTargets; final List providerCatalog; - final List> gatewayProviders; + final List gatewayProviders; final Map raw; } diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index 1518d05f..11617e5f 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -185,6 +185,10 @@ class SingleAgentProvider { required this.providerId, required this.label, required this.badge, + this.logoEmoji = '', + this.supportedTargets = const [], + this.enabled = true, + this.unavailableReason = '', this.source = SingleAgentProviderSource.externalExtension, }); @@ -227,6 +231,10 @@ class SingleAgentProvider { final String providerId; final String label; final String badge; + final String logoEmoji; + final List supportedTargets; + final bool enabled; + final String unavailableReason; final SingleAgentProviderSource source; bool get isUnspecified => providerId.trim().isEmpty; @@ -237,6 +245,10 @@ class SingleAgentProvider { String? providerId, String? label, String? badge, + String? logoEmoji, + List? supportedTargets, + bool? enabled, + String? unavailableReason, SingleAgentProviderSource? source, }) { final resolvedProviderId = normalizeSingleAgentProviderId( @@ -255,6 +267,13 @@ class SingleAgentProvider { label: resolvedLabel, ) : resolvedBadge, + logoEmoji: (logoEmoji ?? this.logoEmoji).trim(), + supportedTargets: + supportedTargets ?? + List.from(this.supportedTargets), + enabled: enabled ?? this.enabled, + unavailableReason: + (unavailableReason ?? this.unavailableReason).trim(), source: source ?? this.source, ); } @@ -263,6 +282,10 @@ class SingleAgentProvider { String? value, { String? label, String? badge, + String? logoEmoji, + List? supportedTargets, + bool? enabled, + String? unavailableReason, }) { final normalized = normalizeSingleAgentProviderId(value ?? ''); final base = switch (normalized) { @@ -281,7 +304,14 @@ class SingleAgentProvider { ), ), }; - return base.copyWith(label: label, badge: badge); + return base.copyWith( + label: label, + badge: badge, + logoEmoji: logoEmoji, + supportedTargets: supportedTargets, + enabled: enabled, + unavailableReason: unavailableReason, + ); } @override @@ -298,7 +328,19 @@ extension SingleAgentProviderCopy on SingleAgentProvider { String? value, { String? label, String? badge, - }) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge); + String? logoEmoji, + List? supportedTargets, + bool? enabled, + String? unavailableReason, + }) => SingleAgentProvider.fromJsonValue( + value, + label: label, + badge: badge, + logoEmoji: logoEmoji, + supportedTargets: supportedTargets, + enabled: enabled, + unavailableReason: unavailableReason, + ); } enum SingleAgentProviderSource { externalExtension } @@ -319,26 +361,9 @@ List normalizeSingleAgentProviderList( const String kCanonicalGatewayProviderId = 'openclaw'; const String kCanonicalGatewayProviderLabel = 'OpenClaw'; -const List kBridgeOwnedSingleAgentProviders = - [ - SingleAgentProvider.codex, - SingleAgentProvider.opencode, - SingleAgentProvider.gemini, - ]; - bool isBridgeOwnedSingleAgentProviderId(String providerId) { final normalized = normalizeSingleAgentProviderId(providerId); - return kBridgeOwnedSingleAgentProviders.any( - (item) => item.providerId == normalized, - ); -} - -List normalizeBridgeOwnedSingleAgentProviderList( - Iterable providers, -) { - return normalizeSingleAgentProviderList( - providers.where( - (provider) => isBridgeOwnedSingleAgentProviderId(provider.providerId), - ), - ); + return normalized == 'codex' || + normalized == 'opencode' || + normalized == 'gemini'; } diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index e008fcaf..cbb48ea9 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -11,7 +11,7 @@ import 'package:xworkmate/widgets/surface_card.dart'; void main() { group('AssistantLowerPaneInternal', () { testWidgets( - 'keeps canonical agent providers visible when live capabilities are unavailable', + 'does not fabricate providers when live capabilities are unavailable', (tester) async { final controller = AppController(); addTearDown(controller.dispose); @@ -33,15 +33,15 @@ void main() { expect( find.byKey(const Key('assistant-provider-menu-item-codex')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const Key('assistant-provider-menu-item-opencode')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const Key('assistant-provider-menu-item-gemini')), - findsOneWidget, + findsNothing, ); expect( find.byKey(const Key('assistant-provider-menu-item-openclaw')), @@ -57,6 +57,18 @@ void main() { SingleAgentProvider.opencode, SingleAgentProvider.gemini, ], + initialGatewayProviderCatalog: [ + SingleAgentProvider.openclaw.copyWith( + logoEmoji: '🦞', + supportedTargets: const [ + AssistantExecutionTarget.gateway, + ], + ), + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], ); addTearDown(controller.dispose); diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index e374baf6..bbf4bddc 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -51,6 +51,78 @@ void main() { expect(provider.label, kCanonicalGatewayProviderLabel); }); + test( + 'switching a session to gateway uses the bridge-provided gateway catalog', + () async { + final controller = AppController( + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.gemini, + ], + initialGatewayProviderCatalog: [ + SingleAgentProvider.openclaw.copyWith( + logoEmoji: '🦞', + supportedTargets: const [ + AssistantExecutionTarget.gateway, + ], + ), + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession('session-1'); + + expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); + expect( + controller.assistantProviderForSession(controller.currentSessionKey), + SingleAgentProvider.codex, + ); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + + final record = controller.requireTaskThreadForSessionInternal( + 'session-1', + ); + expect( + controller.assistantExecutionTargetForSession('session-1').isGateway, + isTrue, + ); + expect( + assistantExecutionTargetFromExecutionMode( + record.executionBinding.executionMode, + ), + AssistantExecutionTarget.gateway, + ); + expect( + controller.assistantProviderForSession('session-1'), + SingleAgentProvider.openclaw, + ); + }, + ); + + test( + 'returns an unavailable provider placeholder when a saved provider is no longer in the bridge catalog', + () { + final controller = AppController(); + addTearDown(controller.dispose); + + final unavailableProvider = controller.resolveProviderForExecutionTarget( + 'gemini', + executionTarget: AssistantExecutionTarget.agent, + ); + + expect(unavailableProvider.providerId, 'gemini'); + expect(unavailableProvider.enabled, isFalse); + }, + ); + test( 'refreshes agent provider catalog when agent mode is selected with an empty catalog', () async {