From 8d960a78994a95fddff487a4b8955bb356d5a267 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 23 Mar 2026 23:28:38 +0800 Subject: [PATCH] Add ACP endpoint settings tab --- lib/app/app_controller_desktop.dart | 80 ++++--- lib/features/settings/settings_page.dart | 225 +++++++++++++++--- ...direct_single_agent_app_server_client.dart | 88 ++++--- lib/runtime/runtime_models.dart | 172 +++++++++++++ lib/runtime/single_agent_runner.dart | 53 +++-- test/features/settings_page_suite.dart | 34 ++- .../direct_single_agent_app_server_suite.dart | 192 ++++++++------- .../external_acp_endpoint_settings_suite.dart | 65 +++++ 8 files changed, 711 insertions(+), 198 deletions(-) create mode 100644 test/runtime/external_acp_endpoint_settings_suite.dart diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 3aedf38c..6e164432 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -192,8 +192,9 @@ class AppController extends ChangeNotifier { late final GoCoreLocator _goCoreLocator; late final SingleAgentRunner _singleAgentRunner; late final MultiAgentOrchestrator _multiAgentOrchestrator; - DirectSingleAgentCapabilities _singleAgentCapabilities = - const DirectSingleAgentCapabilities.unavailable(endpoint: ''); + Map + _singleAgentCapabilitiesByProvider = + const {}; final Map> _assistantThreadMessages = >{}; final Map _assistantThreadRecords = @@ -392,8 +393,7 @@ class AppController extends ChangeNotifier { _settingsController.storedGatewayPasswordMaskForProfile(profileIndex); List get availableSingleAgentProviders => - (_availableSingleAgentProvidersOverride ?? - const [SingleAgentProvider.codex]) + (_availableSingleAgentProvidersOverride ?? kBuiltinExternalAcpProviders) .where((item) => item != SingleAgentProvider.auto) .where(_canUseSingleAgentProvider) .toList(growable: false); @@ -410,9 +410,9 @@ class AppController extends ChangeNotifier { if (provider == SingleAgentProvider.auto) { return hasAnyAvailableSingleAgentProvider; } - return provider == SingleAgentProvider.codex && - _singleAgentCapabilities.available && - _singleAgentCapabilities.supportsCodex; + final capabilities = _singleAgentCapabilitiesByProvider[provider]; + return capabilities?.available == true && + capabilities!.supportsProvider(provider); } SingleAgentProvider? _resolvedSingleAgentProvider( @@ -627,7 +627,7 @@ class AppController extends ChangeNotifier { List get singleAgentProviderOptions => const [ SingleAgentProvider.auto, - SingleAgentProvider.codex, + ...kBuiltinExternalAcpProviders, ]; String singleAgentProviderLabelForSession(String sessionKey) { @@ -4504,16 +4504,29 @@ class AppController extends ChangeNotifier { Future _refreshSingleAgentCapabilities({ bool forceRefresh = false, }) async { - try { - _singleAgentCapabilities = await _singleAgentAppServerClient - .loadCapabilities( - forceRefresh: forceRefresh, - gatewayToken: await settingsController.loadGatewayToken(), - ); - } catch (_) { - _singleAgentCapabilities = - const DirectSingleAgentCapabilities.unavailable(endpoint: ''); + final gatewayToken = await settingsController.loadGatewayToken(); + final next = {}; + for (final provider in kBuiltinExternalAcpProviders) { + final profile = settings.externalAcpEndpointForProvider(provider); + if (!profile.enabled || profile.endpoint.trim().isEmpty) { + next[provider] = const DirectSingleAgentCapabilities.unavailable( + endpoint: '', + ); + continue; + } + try { + next[provider] = await _singleAgentAppServerClient.loadCapabilities( + provider: provider, + forceRefresh: forceRefresh, + gatewayToken: gatewayToken, + ); + } catch (_) { + next[provider] = const DirectSingleAgentCapabilities.unavailable( + endpoint: '', + ); + } } + _singleAgentCapabilitiesByProvider = next; if (!_disposed) { _notifyIfActive(); } @@ -4598,11 +4611,7 @@ class AppController extends ChangeNotifier { } void _registerCodexExternalProvider() { - final endpoint = _resolveGatewayAcpEndpoint()?.replace( - path: '/acp', - query: null, - fragment: null, - ); + final endpoint = _resolveSingleAgentEndpoint(SingleAgentProvider.codex); _runtimeCoordinator.registerExternalCodeAgent( ExternalCodeAgentProvider( id: 'codex', @@ -4778,12 +4787,29 @@ class AppController extends ChangeNotifier { notifyListeners(); } - Uri? _resolveSingleAgentEndpoint() { - final remote = _gatewayProfileBaseUri(settings.primaryRemoteGatewayProfile); - if (remote != null) { - return remote; + Uri? _resolveSingleAgentEndpoint(SingleAgentProvider provider) { + final endpoint = settings + .externalAcpEndpointForProvider(provider) + .endpoint + .trim(); + if (endpoint.isEmpty) { + return null; } - return _gatewayProfileBaseUri(settings.primaryLocalGatewayProfile); + final normalizedInput = endpoint.contains('://') + ? endpoint + : 'ws://$endpoint'; + final uri = Uri.tryParse(normalizedInput); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final scheme = uri.scheme.trim().toLowerCase(); + if (scheme != 'ws' && + scheme != 'wss' && + scheme != 'http' && + scheme != 'https') { + return null; + } + return uri; } Uri? _resolveGatewayAcpEndpoint() { diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 66c23683..c54b43ad 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -71,6 +71,8 @@ class _SettingsPageState extends State { String _aiGatewayTestState = 'idle'; String _aiGatewayTestMessage = ''; String _aiGatewayTestEndpoint = ''; + _GatewayIntegrationSubTab _integrationSubTab = + _GatewayIntegrationSubTab.gateway; int _llmEndpointSlotLimit = 1; int _selectedLlmEndpointIndex = 0; String _aiGatewayNameSyncedValue = ''; @@ -790,41 +792,171 @@ class _SettingsPageState extends State { SettingsSnapshot settings, UiFeatureAccess uiFeatures, ) { + final tabLabel = switch (_integrationSubTab) { + _GatewayIntegrationSubTab.gateway => 'OpenClaw Gateway', + _GatewayIntegrationSubTab.llm => appText('LLM 接入点', 'LLM Endpoints'), + _GatewayIntegrationSubTab.acp => appText('ACP 外部接入', 'External ACP'), + }; return [ - _buildCollapsibleGatewaySection( - context: context, - title: 'OpenClaw Gateway', - expanded: _openClawGatewayExpanded, + SectionTabs( + items: [ + 'OpenClaw Gateway', + appText('LLM 接入点', 'LLM Endpoints'), + appText('ACP 外部接入', 'External ACP'), + ], + value: tabLabel, onChanged: (value) => setState(() { - _openClawGatewayExpanded = value; + _integrationSubTab = switch (value) { + 'OpenClaw Gateway' => _GatewayIntegrationSubTab.gateway, + _ when value == appText('LLM 接入点', 'LLM Endpoints') => + _GatewayIntegrationSubTab.llm, + _ => _GatewayIntegrationSubTab.acp, + }; }), - child: _buildOpenClawGatewayCard(context, controller, settings), ), - if (uiFeatures.supportsVaultServer) ...[ - const SizedBox(height: 16), - _buildCollapsibleGatewaySection( - context: context, - title: appText('Vault Server', 'Vault Server'), - expanded: _vaultServerExpanded, - onChanged: (value) => setState(() { - _vaultServerExpanded = value; - }), - child: _buildVaultProviderCard(context, controller, settings), - ), - ], const SizedBox(height: 16), - _buildCollapsibleGatewaySection( - context: context, - title: appText('LLM 接入点', 'LLM Endpoints'), - expanded: _aiGatewayExpanded, - onChanged: (value) => setState(() { - _aiGatewayExpanded = value; - }), - child: _buildLlmEndpointManager(context, controller, settings), - ), + ...switch (_integrationSubTab) { + _GatewayIntegrationSubTab.gateway => [ + _buildCollapsibleGatewaySection( + context: context, + title: 'OpenClaw Gateway', + expanded: _openClawGatewayExpanded, + onChanged: (value) => setState(() { + _openClawGatewayExpanded = value; + }), + child: _buildOpenClawGatewayCard(context, controller, settings), + ), + if (uiFeatures.supportsVaultServer) ...[ + const SizedBox(height: 16), + _buildCollapsibleGatewaySection( + context: context, + title: appText('Vault Server', 'Vault Server'), + expanded: _vaultServerExpanded, + onChanged: (value) => setState(() { + _vaultServerExpanded = value; + }), + child: _buildVaultProviderCard(context, controller, settings), + ), + ], + ], + _GatewayIntegrationSubTab.llm => [ + _buildCollapsibleGatewaySection( + context: context, + title: appText('LLM 接入点', 'LLM Endpoints'), + expanded: _aiGatewayExpanded, + onChanged: (value) => setState(() { + _aiGatewayExpanded = value; + }), + child: _buildLlmEndpointManager(context, controller, settings), + ), + ], + _GatewayIntegrationSubTab.acp => [ + _buildExternalAcpEndpointManager(context, controller, settings), + ], + }, ]; } + Widget _buildExternalAcpEndpointManager( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + final theme = Theme.of(context); + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('外部 ACP Server Endpoint', 'External ACP Server Endpoints'), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + '第一批内置 4 个 provider:Codex、OpenCode、Claude、Gemini。每个 provider 都可以自定义接入自己的 ACP Server Endpoint,协议支持 ws / wss / http / https。Gateway profile 与 ACP endpoint 分开存储,后续可在这个列表上扩展自定义 provider。', + 'The first batch includes 4 built-in providers: Codex, OpenCode, Claude, and Gemini. Each provider can point to its own ACP server endpoint with ws / wss / http / https. Gateway profiles and ACP endpoints are stored separately, and this list is designed to extend to custom providers later.', + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + ...kBuiltinExternalAcpProviders.map( + (provider) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildExternalAcpProviderCard( + context, + controller, + settings, + provider, + ), + ), + ), + ], + ), + ); + } + + Widget _buildExternalAcpProviderCard( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + SingleAgentProvider provider, + ) { + final profile = settings.externalAcpEndpointForProvider(provider); + final endpoint = profile.endpoint.trim(); + final configured = endpoint.isNotEmpty; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + provider.label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + _StatusChip( + label: configured + ? appText('已配置', 'Configured') + : appText('未配置', 'Empty'), + tone: configured ? _StatusChipTone.ready : _StatusChipTone.idle, + ), + ], + ), + const SizedBox(height: 12), + _EditableField( + label: appText( + '${provider.label} ACP Endpoint', + '${provider.label} ACP Endpoint', + ), + value: endpoint, + onSubmitted: (value) => _saveSettings( + controller, + settings.copyWithExternalAcpEndpointForProvider( + provider, + profile.copyWith(endpoint: value), + ), + ), + ), + Text( + appText( + '示例:ws://127.0.0.1:9001、wss://acp.example.com/rpc、http://127.0.0.1:8080、https://agent.example.com', + 'Examples: ws://127.0.0.1:9001, wss://acp.example.com/rpc, http://127.0.0.1:8080, https://agent.example.com', + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + Widget _buildLlmEndpointManager( BuildContext context, AppController controller, @@ -4591,6 +4723,8 @@ class _WorkflowStep extends StatelessWidget { } } +enum _GatewayIntegrationSubTab { gateway, llm, acp } + enum _LlmEndpointSlot { aiGateway, ollamaLocal, ollamaCloud } const List<_LlmEndpointSlot> _llmEndpointSlots = <_LlmEndpointSlot>[ @@ -4598,3 +4732,40 @@ const List<_LlmEndpointSlot> _llmEndpointSlots = <_LlmEndpointSlot>[ _LlmEndpointSlot.ollamaLocal, _LlmEndpointSlot.ollamaCloud, ]; + +enum _StatusChipTone { idle, ready } + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.label, required this.tone}); + + final String label; + final _StatusChipTone tone; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final (background, foreground) = switch (tone) { + _StatusChipTone.ready => ( + colorScheme.primaryContainer, + colorScheme.onPrimaryContainer, + ), + _StatusChipTone.idle => ( + colorScheme.surfaceContainerHighest, + colorScheme.onSurfaceVariant, + ), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: foreground), + ), + ); + } +} diff --git a/lib/runtime/direct_single_agent_app_server_client.dart b/lib/runtime/direct_single_agent_app_server_client.dart index d0c17816..e83799aa 100644 --- a/lib/runtime/direct_single_agent_app_server_client.dart +++ b/lib/runtime/direct_single_agent_app_server_client.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'runtime_models.dart'; + class DirectSingleAgentCapabilities { const DirectSingleAgentCapabilities({ required this.available, - required this.supportsCodex, + required this.supportedProviders, required this.endpoint, this.errorMessage, }); @@ -14,12 +16,17 @@ class DirectSingleAgentCapabilities { required this.endpoint, this.errorMessage, }) : available = false, - supportsCodex = false; + supportedProviders = const []; final bool available; - final bool supportsCodex; + final List supportedProviders; final String endpoint; final String? errorMessage; + + bool get supportsCodex => supportsProvider(SingleAgentProvider.codex); + + bool supportsProvider(SingleAgentProvider provider) => + supportedProviders.contains(provider); } class DirectSingleAgentRunResult { @@ -39,6 +46,7 @@ class DirectSingleAgentRunResult { class DirectSingleAgentRunRequest { const DirectSingleAgentRunRequest({ required this.sessionId, + required this.provider, required this.prompt, required this.model, required this.workingDirectory, @@ -47,6 +55,7 @@ class DirectSingleAgentRunRequest { }); final String sessionId; + final SingleAgentProvider provider; final String prompt; final String model; final String workingDirectory; @@ -57,36 +66,41 @@ class DirectSingleAgentRunRequest { class DirectSingleAgentAppServerClient { DirectSingleAgentAppServerClient({required this.endpointResolver}); - final Uri? Function() endpointResolver; + final Uri? Function(SingleAgentProvider provider) endpointResolver; final Map _activeConnections = {}; final Map _threadIds = {}; final Set _abortedSessions = {}; - DirectSingleAgentCapabilities _cachedCapabilities = - const DirectSingleAgentCapabilities.unavailable(endpoint: ''); - DateTime? _capabilitiesRefreshedAt; + final Map + _cachedCapabilities = {}; + final Map _capabilitiesRefreshedAt = + {}; Future loadCapabilities({ + required SingleAgentProvider provider, bool forceRefresh = false, String gatewayToken = '', }) async { + final cached = _cachedCapabilities[provider]; + final refreshedAt = _capabilitiesRefreshedAt[provider]; if (!forceRefresh && - _capabilitiesRefreshedAt != null && - DateTime.now().difference(_capabilitiesRefreshedAt!) < - const Duration(seconds: 15)) { - return _cachedCapabilities; + cached != null && + refreshedAt != null && + DateTime.now().difference(refreshedAt) < const Duration(seconds: 15)) { + return cached; } - final endpoint = _resolveWebSocketEndpoint(); + final endpoint = _resolveWebSocketEndpoint(provider); if (endpoint == null) { - _cachedCapabilities = const DirectSingleAgentCapabilities.unavailable( + final unavailable = const DirectSingleAgentCapabilities.unavailable( endpoint: '', errorMessage: 'Single-agent app-server endpoint is not configured.', ); - _capabilitiesRefreshedAt = DateTime.now(); - return _cachedCapabilities; + _cachedCapabilities[provider] = unavailable; + _capabilitiesRefreshedAt[provider] = DateTime.now(); + return unavailable; } _DirectAppServerConnection? connection; @@ -96,28 +110,28 @@ class DirectSingleAgentAppServerClient { gatewayToken: gatewayToken, ); await connection.initialize(); - _cachedCapabilities = DirectSingleAgentCapabilities( + _cachedCapabilities[provider] = DirectSingleAgentCapabilities( available: true, - supportsCodex: true, + supportedProviders: [provider], endpoint: endpoint.toString(), ); } catch (error) { - _cachedCapabilities = DirectSingleAgentCapabilities.unavailable( + _cachedCapabilities[provider] = DirectSingleAgentCapabilities.unavailable( endpoint: endpoint.toString(), errorMessage: error.toString(), ); } finally { - _capabilitiesRefreshedAt = DateTime.now(); + _capabilitiesRefreshedAt[provider] = DateTime.now(); await connection?.close(); } - return _cachedCapabilities; + return _cachedCapabilities[provider]!; } Future run( DirectSingleAgentRunRequest request, ) async { - final endpoint = _resolveWebSocketEndpoint(); + final endpoint = _resolveWebSocketEndpoint(request.provider); if (endpoint == null) { return const DirectSingleAgentRunResult( success: false, @@ -334,8 +348,8 @@ class DirectSingleAgentAppServerClient { return threadId; } - Uri? _resolveWebSocketEndpoint() { - final base = endpointResolver(); + Uri? _resolveWebSocketEndpoint(SingleAgentProvider provider) { + final base = endpointResolver(provider); if (base == null) { return null; } @@ -378,15 +392,16 @@ class _DirectAppServerConnection { if (normalizedToken.isNotEmpty) { headers[HttpHeaders.authorizationHeader] = 'Bearer $normalizedToken'; } - final socket = await WebSocket.connect( - endpoint.toString(), - headers: headers.isEmpty ? null : headers, - ).timeout( - const Duration(seconds: 8), - onTimeout: () => throw TimeoutException( - 'Single-agent app-server websocket connect timed out.', - ), - ); + final socket = + await WebSocket.connect( + endpoint.toString(), + headers: headers.isEmpty ? null : headers, + ).timeout( + const Duration(seconds: 8), + onTimeout: () => throw TimeoutException( + 'Single-agent app-server websocket connect timed out.', + ), + ); final connection = _DirectAppServerConnection(socket); connection._attach(); return connection; @@ -399,10 +414,7 @@ class _DirectAppServerConnection { await request( 'initialize', params: const { - 'clientInfo': { - 'name': 'xworkmate', - 'version': '0', - }, + 'clientInfo': {'name': 'xworkmate', 'version': '0'}, 'capabilities': { 'optOutNotificationMethods': [], }, @@ -432,7 +444,9 @@ class _DirectAppServerConnection { timeout, onTimeout: () { _pendingRequests.remove(id); - throw TimeoutException('Single-agent app-server request $method timed out.'); + throw TimeoutException( + 'Single-agent app-server request $method timed out.', + ); }, ); } diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 602876cf..c6274efc 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -107,6 +107,135 @@ extension SingleAgentProviderCopy on SingleAgentProvider { } } +const List kBuiltinExternalAcpProviders = + [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.claude, + SingleAgentProvider.gemini, + ]; + +class ExternalAcpEndpointProfile { + const ExternalAcpEndpointProfile({ + required this.providerKey, + required this.label, + required this.endpoint, + required this.enabled, + }); + + final String providerKey; + final String label; + final String endpoint; + final bool enabled; + + factory ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider provider, + ) { + return ExternalAcpEndpointProfile( + providerKey: provider.providerId, + label: provider.label, + endpoint: '', + enabled: true, + ); + } + + ExternalAcpEndpointProfile copyWith({ + String? providerKey, + String? label, + String? endpoint, + bool? enabled, + }) { + return ExternalAcpEndpointProfile( + providerKey: (providerKey ?? this.providerKey).trim(), + label: (label ?? this.label).trim(), + endpoint: (endpoint ?? this.endpoint).trim(), + enabled: enabled ?? this.enabled, + ); + } + + SingleAgentProvider? get builtinProvider { + final normalized = providerKey.trim().toLowerCase(); + for (final provider in kBuiltinExternalAcpProviders) { + if (provider.providerId == normalized) { + return provider; + } + } + return null; + } + + bool get isBuiltin => builtinProvider != null; + + Map toJson() { + return { + 'providerKey': providerKey, + 'label': label, + 'endpoint': endpoint, + 'enabled': enabled, + }; + } + + factory ExternalAcpEndpointProfile.fromJson(Map json) { + final providerKey = json['providerKey']?.toString().trim() ?? ''; + final builtin = SingleAgentProviderCopy.fromJsonValue(providerKey); + final fallbackLabel = builtin == SingleAgentProvider.auto + ? providerKey + : builtin.label; + return ExternalAcpEndpointProfile( + providerKey: providerKey, + label: json['label']?.toString().trim().isNotEmpty == true + ? json['label'].toString().trim() + : fallbackLabel, + endpoint: json['endpoint']?.toString().trim() ?? '', + enabled: json['enabled'] as bool? ?? true, + ); + } +} + +List normalizeExternalAcpEndpoints({ + Iterable? profiles, +}) { + final incoming = + profiles?.toList(growable: false) ?? const []; + final byKey = {}; + for (final item in incoming) { + final key = item.providerKey.trim().toLowerCase(); + if (key.isEmpty || byKey.containsKey(key)) { + continue; + } + byKey[key] = item.copyWith(providerKey: key); + } + + final normalized = [ + for (final provider in kBuiltinExternalAcpProviders) + byKey.remove(provider.providerId) ?? + ExternalAcpEndpointProfile.defaultsForProvider(provider), + ...byKey.values, + ]; + return List.unmodifiable(normalized); +} + +List replaceExternalAcpEndpointForProvider( + List profiles, + SingleAgentProvider provider, + ExternalAcpEndpointProfile profile, +) { + final normalized = normalizeExternalAcpEndpoints(profiles: profiles); + final next = List.from(normalized); + final index = next.indexWhere( + (item) => item.providerKey.trim().toLowerCase() == provider.providerId, + ); + final resolved = profile.copyWith( + providerKey: provider.providerId, + label: profile.label.trim().isEmpty ? provider.label : profile.label, + ); + if (index == -1) { + next.add(resolved); + } else { + next[index] = resolved; + } + return normalizeExternalAcpEndpoints(profiles: next); +} + class AssistantThreadConnectionState { const AssistantThreadConnectionState({ required this.executionTarget, @@ -1165,6 +1294,7 @@ class SettingsSnapshot { required this.defaultModel, required this.defaultProvider, required this.gatewayProfiles, + required this.externalAcpEndpoints, required this.ollamaLocal, required this.ollamaCloud, required this.vault, @@ -1199,6 +1329,7 @@ class SettingsSnapshot { final String defaultModel; final String defaultProvider; final List gatewayProfiles; + final List externalAcpEndpoints; final OllamaLocalConfig ollamaLocal; final OllamaCloudConfig ollamaCloud; final VaultConfig vault; @@ -1234,6 +1365,7 @@ class SettingsSnapshot { defaultModel: '', defaultProvider: 'gateway', gatewayProfiles: normalizeGatewayProfiles(), + externalAcpEndpoints: normalizeExternalAcpEndpoints(), ollamaLocal: OllamaLocalConfig.defaults(), ollamaCloud: OllamaCloudConfig.defaults(), vault: VaultConfig.defaults(), @@ -1270,6 +1402,7 @@ class SettingsSnapshot { String? defaultModel, String? defaultProvider, List? gatewayProfiles, + List? externalAcpEndpoints, OllamaLocalConfig? ollamaLocal, OllamaCloudConfig? ollamaCloud, VaultConfig? vault, @@ -1294,6 +1427,9 @@ class SettingsSnapshot { final resolvedGatewayProfiles = gatewayProfiles != null ? normalizeGatewayProfiles(profiles: gatewayProfiles) : this.gatewayProfiles; + final resolvedExternalAcpEndpoints = externalAcpEndpoints != null + ? normalizeExternalAcpEndpoints(profiles: externalAcpEndpoints) + : this.externalAcpEndpoints; return SettingsSnapshot( appLanguage: appLanguage ?? this.appLanguage, appActive: appActive ?? this.appActive, @@ -1307,6 +1443,7 @@ class SettingsSnapshot { defaultModel: defaultModel ?? this.defaultModel, defaultProvider: defaultProvider ?? this.defaultProvider, gatewayProfiles: resolvedGatewayProfiles, + externalAcpEndpoints: resolvedExternalAcpEndpoints, ollamaLocal: ollamaLocal ?? this.ollamaLocal, ollamaCloud: ollamaCloud ?? this.ollamaCloud, vault: vault ?? this.vault, @@ -1354,6 +1491,9 @@ class SettingsSnapshot { 'gatewayProfiles': gatewayProfiles .map((item) => item.toJson()) .toList(growable: false), + 'externalAcpEndpoints': externalAcpEndpoints + .map((item) => item.toJson()) + .toList(growable: false), 'ollamaLocal': ollamaLocal.toJson(), 'ollamaCloud': ollamaCloud.toJson(), 'vault': vault.toJson(), @@ -1433,6 +1573,15 @@ class SettingsSnapshot { GatewayConnectionProfile.fromJson(item.cast()), ), ); + final externalAcpEndpoints = normalizeExternalAcpEndpoints( + profiles: ((json['externalAcpEndpoints'] as List?) ?? const []) + .whereType() + .map( + (item) => ExternalAcpEndpointProfile.fromJson( + item.cast(), + ), + ), + ); return SettingsSnapshot( appLanguage: AppLanguageCopy.fromJsonValue( json['appLanguage'] as String?, @@ -1461,6 +1610,7 @@ class SettingsSnapshot { json['defaultProvider'] as String? ?? SettingsSnapshot.defaults().defaultProvider, gatewayProfiles: gatewayProfiles, + externalAcpEndpoints: externalAcpEndpoints, ollamaLocal: OllamaLocalConfig.fromJson( (json['ollamaLocal'] as Map?)?.cast() ?? const {}, ), @@ -1566,6 +1716,28 @@ class SettingsSnapshot { } return copyWithGatewayProfileAt(index, profile); } + + ExternalAcpEndpointProfile externalAcpEndpointForProvider( + SingleAgentProvider provider, + ) { + return externalAcpEndpoints.firstWhere( + (item) => item.providerKey.trim().toLowerCase() == provider.providerId, + orElse: () => ExternalAcpEndpointProfile.defaultsForProvider(provider), + ); + } + + SettingsSnapshot copyWithExternalAcpEndpointForProvider( + SingleAgentProvider provider, + ExternalAcpEndpointProfile profile, + ) { + return copyWith( + externalAcpEndpoints: replaceExternalAcpEndpointForProvider( + externalAcpEndpoints, + provider, + profile, + ), + ); + } } class GatewayConnectionSnapshot { diff --git a/lib/runtime/single_agent_runner.dart b/lib/runtime/single_agent_runner.dart index 87540054..ef914a98 100644 --- a/lib/runtime/single_agent_runner.dart +++ b/lib/runtime/single_agent_runner.dart @@ -92,31 +92,49 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { required String gatewayToken, }) async { try { - final capabilities = await _appServerClient.loadCapabilities( - gatewayToken: gatewayToken, - ); - if (!capabilities.available || !capabilities.supportsCodex) { + if (selection != SingleAgentProvider.auto) { + final capabilities = await _appServerClient.loadCapabilities( + provider: selection, + gatewayToken: gatewayToken, + ); + if (!capabilities.available || + !capabilities.supportsProvider(selection)) { + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: null, + fallbackReason: + capabilities.errorMessage ?? + '${selection.label} endpoint is unavailable.', + ); + } return SingleAgentProviderResolution( selection: selection, - resolvedProvider: null, - fallbackReason: - capabilities.errorMessage ?? - 'Single-agent app-server is unavailable.', + resolvedProvider: selection, + fallbackReason: null, ); } - if (selection != SingleAgentProvider.auto && - selection != SingleAgentProvider.codex) { - return SingleAgentProviderResolution( - selection: selection, - resolvedProvider: null, - fallbackReason: - '${selection.label} is unavailable from the direct app-server endpoint.', + + String? fallbackReason; + for (final provider in kBuiltinExternalAcpProviders) { + final capabilities = await _appServerClient.loadCapabilities( + provider: provider, + gatewayToken: gatewayToken, ); + if (capabilities.available && capabilities.supportsProvider(provider)) { + return SingleAgentProviderResolution( + selection: selection, + resolvedProvider: provider, + fallbackReason: null, + ); + } + fallbackReason ??= capabilities.errorMessage; } return SingleAgentProviderResolution( selection: selection, - resolvedProvider: SingleAgentProvider.codex, - fallbackReason: null, + resolvedProvider: null, + fallbackReason: + fallbackReason ?? + 'No external ACP endpoint is currently available.', ); } catch (error) { return SingleAgentProviderResolution( @@ -133,6 +151,7 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { final result = await _appServerClient.run( DirectSingleAgentRunRequest( sessionId: request.sessionId, + provider: request.provider, prompt: _augmentPrompt(request), model: request.model, workingDirectory: request.workingDirectory, diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 0a8f4dad..c9060f97 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -147,9 +147,11 @@ void main() { await tester.tap(find.text('集成')); await tester.pumpAndSettle(); - expect(find.text('OpenClaw Gateway'), findsOneWidget); + expect(find.text('OpenClaw Gateway'), findsWidgets); + expect(find.text('LLM 接入点'), findsOneWidget); + expect(find.text('ACP 外部接入'), findsOneWidget); expect(find.text('Vault Server'), findsNothing); - expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsOneWidget); + expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsNothing); expect(find.byKey(const ValueKey('gateway-mode-field')), findsNothing); expect(find.text('认证诊断'), findsNothing); expect(find.byKey(const ValueKey('gateway-test-button')), findsOneWidget); @@ -216,6 +218,30 @@ void main() { expect(find.text('Vault Server'), findsOneWidget); }); + testWidgets('SettingsPage integration tab exposes ACP provider endpoints', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: SettingsPage(controller: controller), + platform: TargetPlatform.macOS, + ); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + await tester.tap(find.text('ACP 外部接入').first); + await tester.pumpAndSettle(); + + expect(find.text('外部 ACP Server Endpoint'), findsOneWidget); + expect(find.text('Codex'), findsOneWidget); + expect(find.text('OpenCode'), findsOneWidget); + expect(find.text('Claude'), findsOneWidget); + expect(find.text('Gemini'), findsOneWidget); + expect(find.textContaining('ws://127.0.0.1:9001'), findsWidgets); + }); + testWidgets('SettingsPage gateway sections can collapse individually', ( WidgetTester tester, ) async { @@ -230,7 +256,7 @@ void main() { await tester.tap(find.text('集成')); await tester.pumpAndSettle(); - await tester.tap(find.text('OpenClaw Gateway')); + await tester.tap(find.byTooltip('折叠').first); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('gateway-host-field')), findsNothing); @@ -240,7 +266,7 @@ void main() { findsNothing, ); - await tester.tap(find.text('OpenClaw Gateway')); + await tester.tap(find.byTooltip('展开').first); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('gateway-host-field')), findsOneWidget); diff --git a/test/runtime/direct_single_agent_app_server_suite.dart b/test/runtime/direct_single_agent_app_server_suite.dart index 220c5556..06bfa5f8 100644 --- a/test/runtime/direct_single_agent_app_server_suite.dart +++ b/test/runtime/direct_single_agent_app_server_suite.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/runtime/direct_single_agent_app_server_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; void main() { group('DirectSingleAgentAppServerClient', () { @@ -15,10 +16,12 @@ void main() { addTearDown(server.close); final client = DirectSingleAgentAppServerClient( - endpointResolver: () => server.baseHttpUri, + endpointResolver: (_) => server.baseHttpUri, ); - final capabilities = await client.loadCapabilities(); + final capabilities = await client.loadCapabilities( + provider: SingleAgentProvider.codex, + ); expect(capabilities.available, isTrue); expect(capabilities.supportsCodex, isTrue); @@ -31,7 +34,7 @@ void main() { addTearDown(server.close); final client = DirectSingleAgentAppServerClient( - endpointResolver: () => server.baseHttpUri, + endpointResolver: (_) => server.baseHttpUri, ); addTearDown(client.dispose); @@ -39,6 +42,7 @@ void main() { final result = await client.run( const DirectSingleAgentRunRequest( sessionId: 'session-1', + provider: SingleAgentProvider.codex, prompt: 'hello world', model: 'gpt-4.1', workingDirectory: '/tmp', @@ -49,11 +53,10 @@ void main() { expect(result.success, isTrue); expect(result.output, 'hello world from app server'); expect(deltas.join(), 'hello world from app server'); - expect(server.methods, containsAll([ - 'initialize', - 'thread/start', - 'turn/start', - ])); + expect( + server.methods, + containsAll(['initialize', 'thread/start', 'turn/start']), + ); expect(server.authorizationHeaders, contains('Bearer token-1')); }); @@ -62,13 +65,14 @@ void main() { addTearDown(server.close); final client = DirectSingleAgentAppServerClient( - endpointResolver: () => server.baseHttpUri, + endpointResolver: (_) => server.baseHttpUri, ); addTearDown(client.dispose); final runFuture = client.run( const DirectSingleAgentRunRequest( sessionId: 'session-abort', + provider: SingleAgentProvider.codex, prompt: 'abort me', model: 'gpt-4.1', workingDirectory: '/tmp', @@ -93,7 +97,8 @@ class _FakeAppServer { final bool delayCompletion; final List methods = []; final List authorizationHeaders = []; - final Map> _methodWaiters = >{}; + final Map> _methodWaiters = + >{}; int _threadCounter = 0; int get port => _server.port; @@ -123,7 +128,8 @@ class _FakeAppServer { authorizationHeaders.add( request.headers.value(HttpHeaders.authorizationHeader) ?? '', ); - if (request.uri.path == '/' && WebSocketTransformer.isUpgradeRequest(request)) { + if (request.uri.path == '/' && + WebSocketTransformer.isUpgradeRequest(request)) { final socket = await WebSocketTransformer.upgrade(request); unawaited(_handleSocket(socket)); continue; @@ -146,78 +152,92 @@ class _FakeAppServer { _methodWaiters.remove(method)?.complete(); switch (method) { case 'initialize': - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'serverInfo': {'name': 'fake-codex'}, - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'serverInfo': {'name': 'fake-codex'}, + }, + }), + ); break; case 'initialized': break; case 'thread/start': _threadCounter += 1; - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'id': 'thread-$_threadCounter', - 'path': params['cwd'] ?? '/tmp', - 'ephemeral': false, - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': 'thread-$_threadCounter', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + }), + ); break; case 'thread/resume': - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'id': params['threadId'] ?? 'thread-resumed', - 'path': params['cwd'] ?? '/tmp', - 'ephemeral': false, - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': params['threadId'] ?? 'thread-resumed', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + }), + ); break; case 'turn/start': final threadId = params['threadId']?.toString() ?? 'thread-1'; - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'result': { - 'id': 'turn-1', - 'threadId': threadId, - 'status': 'started', - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'id': 'turn-1', + 'threadId': threadId, + 'status': 'started', + }, + }), + ); unawaited(_emitTurn(socket, threadId)); break; case 'turn/interrupt': final threadId = params['threadId']?.toString() ?? 'thread-1'; - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'result': {'ok': true}, - })); - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'method': 'turn/error', - 'params': { - 'threadId': threadId, - 'message': 'aborted', - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'result': {'ok': true}, + }), + ); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'turn/error', + 'params': { + 'threadId': threadId, + 'message': 'aborted', + }, + }), + ); await socket.close(); break; default: - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'error': { - 'code': -32601, - 'message': 'unknown method $method', - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': -32601, + 'message': 'unknown method $method', + }, + }), + ); } } } @@ -226,15 +246,17 @@ class _FakeAppServer { const parts = ['hello ', 'world ', 'from app server']; for (final part in parts) { try { - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'method': 'item/agentMessage/delta', - 'params': { - 'threadId': threadId, - 'turnId': 'turn-1', - 'delta': part, - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'item/agentMessage/delta', + 'params': { + 'threadId': threadId, + 'turnId': 'turn-1', + 'delta': part, + }, + }), + ); } catch (_) { return; } @@ -243,14 +265,13 @@ class _FakeAppServer { if (delayCompletion) { return; } - socket.add(jsonEncode({ - 'jsonrpc': '2.0', - 'method': 'turn/completed', - 'params': { - 'threadId': threadId, - 'turnId': 'turn-1', - }, - })); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'method': 'turn/completed', + 'params': {'threadId': threadId, 'turnId': 'turn-1'}, + }), + ); } } @@ -282,11 +303,10 @@ Map _asMap(Object? value) { } extension on DirectSingleAgentRunRequest { - DirectSingleAgentRunRequest copyWith({ - void Function(String text)? onOutput, - }) { + DirectSingleAgentRunRequest copyWith({void Function(String text)? onOutput}) { return DirectSingleAgentRunRequest( sessionId: sessionId, + provider: provider, prompt: prompt, model: model, workingDirectory: workingDirectory, diff --git a/test/runtime/external_acp_endpoint_settings_suite.dart b/test/runtime/external_acp_endpoint_settings_suite.dart new file mode 100644 index 00000000..e3c42ff5 --- /dev/null +++ b/test/runtime/external_acp_endpoint_settings_suite.dart @@ -0,0 +1,65 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + group('External ACP endpoint settings', () { + test('defaults expose the first batch of built-in providers', () { + final snapshot = SettingsSnapshot.defaults(); + + expect( + snapshot.externalAcpEndpoints + .take(4) + .map((item) => item.providerKey) + .toList(growable: false), + const ['codex', 'opencode', 'claude', 'gemini'], + ); + }); + + test('round-trip preserves built-in entries and custom extensions', () { + final snapshot = SettingsSnapshot.defaults().copyWith( + externalAcpEndpoints: normalizeExternalAcpEndpoints( + profiles: [ + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith(endpoint: 'ws://127.0.0.1:9001'), + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.opencode, + ).copyWith(endpoint: 'https://opencode.example.com'), + const ExternalAcpEndpointProfile( + providerKey: 'custom-lab', + label: 'Custom Lab', + endpoint: 'wss://lab.example.com/acp', + enabled: true, + ), + ], + ), + ); + + final decoded = SettingsSnapshot.fromJson(snapshot.toJson()); + + expect( + decoded + .externalAcpEndpointForProvider(SingleAgentProvider.codex) + .endpoint, + 'ws://127.0.0.1:9001', + ); + expect( + decoded + .externalAcpEndpointForProvider(SingleAgentProvider.opencode) + .endpoint, + 'https://opencode.example.com', + ); + expect( + decoded.externalAcpEndpoints.any( + (item) => + item.providerKey == 'custom-lab' && + item.endpoint == 'wss://lab.example.com/acp', + ), + isTrue, + ); + }); + }); +}