Add ACP endpoint settings tab

This commit is contained in:
Haitao Pan 2026-03-23 23:28:38 +08:00
parent 42dc9e6e3c
commit 8d960a7899
8 changed files with 711 additions and 198 deletions

View File

@ -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<SingleAgentProvider, DirectSingleAgentCapabilities>
_singleAgentCapabilitiesByProvider =
const <SingleAgentProvider, DirectSingleAgentCapabilities>{};
final Map<String, List<GatewayChatMessage>> _assistantThreadMessages =
<String, List<GatewayChatMessage>>{};
final Map<String, AssistantThreadRecord> _assistantThreadRecords =
@ -392,8 +393,7 @@ class AppController extends ChangeNotifier {
_settingsController.storedGatewayPasswordMaskForProfile(profileIndex);
List<SingleAgentProvider> get availableSingleAgentProviders =>
(_availableSingleAgentProvidersOverride ??
const <SingleAgentProvider>[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<SingleAgentProvider> get singleAgentProviderOptions =>
const <SingleAgentProvider>[
SingleAgentProvider.auto,
SingleAgentProvider.codex,
...kBuiltinExternalAcpProviders,
];
String singleAgentProviderLabelForSession(String sessionKey) {
@ -4504,16 +4504,29 @@ class AppController extends ChangeNotifier {
Future<void> _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 = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
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() {

View File

@ -71,6 +71,8 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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: <String>[
'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 => <Widget>[
_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 => <Widget>[
_buildCollapsibleGatewaySection(
context: context,
title: appText('LLM 接入点', 'LLM Endpoints'),
expanded: _aiGatewayExpanded,
onChanged: (value) => setState(() {
_aiGatewayExpanded = value;
}),
child: _buildLlmEndpointManager(context, controller, settings),
),
],
_GatewayIntegrationSubTab.acp => <Widget>[
_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 个 providerCodex、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),
),
);
}
}

View File

@ -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 <SingleAgentProvider>[];
final bool available;
final bool supportsCodex;
final List<SingleAgentProvider> 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<String, _DirectAppServerConnection> _activeConnections =
<String, _DirectAppServerConnection>{};
final Map<String, String> _threadIds = <String, String>{};
final Set<String> _abortedSessions = <String>{};
DirectSingleAgentCapabilities _cachedCapabilities =
const DirectSingleAgentCapabilities.unavailable(endpoint: '');
DateTime? _capabilitiesRefreshedAt;
final Map<SingleAgentProvider, DirectSingleAgentCapabilities>
_cachedCapabilities = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
final Map<SingleAgentProvider, DateTime> _capabilitiesRefreshedAt =
<SingleAgentProvider, DateTime>{};
Future<DirectSingleAgentCapabilities> 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: <SingleAgentProvider>[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<DirectSingleAgentRunResult> 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 <String, dynamic>{
'clientInfo': <String, dynamic>{
'name': 'xworkmate',
'version': '0',
},
'clientInfo': <String, dynamic>{'name': 'xworkmate', 'version': '0'},
'capabilities': <String, dynamic>{
'optOutNotificationMethods': <String>[],
},
@ -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.',
);
},
);
}

View File

@ -107,6 +107,135 @@ extension SingleAgentProviderCopy on SingleAgentProvider {
}
}
const List<SingleAgentProvider> kBuiltinExternalAcpProviders =
<SingleAgentProvider>[
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<String, dynamic> toJson() {
return {
'providerKey': providerKey,
'label': label,
'endpoint': endpoint,
'enabled': enabled,
};
}
factory ExternalAcpEndpointProfile.fromJson(Map<String, dynamic> 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<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
Iterable<ExternalAcpEndpointProfile>? profiles,
}) {
final incoming =
profiles?.toList(growable: false) ?? const <ExternalAcpEndpointProfile>[];
final byKey = <String, ExternalAcpEndpointProfile>{};
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 = <ExternalAcpEndpointProfile>[
for (final provider in kBuiltinExternalAcpProviders)
byKey.remove(provider.providerId) ??
ExternalAcpEndpointProfile.defaultsForProvider(provider),
...byKey.values,
];
return List<ExternalAcpEndpointProfile>.unmodifiable(normalized);
}
List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
List<ExternalAcpEndpointProfile> profiles,
SingleAgentProvider provider,
ExternalAcpEndpointProfile profile,
) {
final normalized = normalizeExternalAcpEndpoints(profiles: profiles);
final next = List<ExternalAcpEndpointProfile>.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<GatewayConnectionProfile> gatewayProfiles;
final List<ExternalAcpEndpointProfile> 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<GatewayConnectionProfile>? gatewayProfiles,
List<ExternalAcpEndpointProfile>? 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<String, dynamic>()),
),
);
final externalAcpEndpoints = normalizeExternalAcpEndpoints(
profiles: ((json['externalAcpEndpoints'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => ExternalAcpEndpointProfile.fromJson(
item.cast<String, dynamic>(),
),
),
);
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<String, dynamic>() ?? 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 {

View File

@ -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,

View File

@ -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);

View File

@ -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(<String>[
'initialize',
'thread/start',
'turn/start',
]));
expect(
server.methods,
containsAll(<String>['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<String> methods = <String>[];
final List<String> authorizationHeaders = <String>[];
final Map<String, Completer<void>> _methodWaiters = <String, Completer<void>>{};
final Map<String, Completer<void>> _methodWaiters =
<String, Completer<void>>{};
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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'serverInfo': <String, dynamic>{'name': 'fake-codex'},
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'serverInfo': <String, dynamic>{'name': 'fake-codex'},
},
}),
);
break;
case 'initialized':
break;
case 'thread/start':
_threadCounter += 1;
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}),
);
break;
case 'thread/resume':
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': params['threadId'] ?? 'thread-resumed',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': 'turn-1',
'threadId': threadId,
'status': 'started',
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'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(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{'ok': true},
}));
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/error',
'params': <String, dynamic>{
'threadId': threadId,
'message': 'aborted',
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{'ok': true},
}),
);
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/error',
'params': <String, dynamic>{
'threadId': threadId,
'message': 'aborted',
},
}),
);
await socket.close();
break;
default:
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'error': <String, dynamic>{
'code': -32601,
'message': 'unknown method $method',
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'error': <String, dynamic>{
'code': -32601,
'message': 'unknown method $method',
},
}),
);
}
}
}
@ -226,15 +246,17 @@ class _FakeAppServer {
const parts = <String>['hello ', 'world ', 'from app server'];
for (final part in parts) {
try {
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'item/agentMessage/delta',
'params': <String, dynamic>{
'threadId': threadId,
'turnId': 'turn-1',
'delta': part,
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'item/agentMessage/delta',
'params': <String, dynamic>{
'threadId': threadId,
'turnId': 'turn-1',
'delta': part,
},
}),
);
} catch (_) {
return;
}
@ -243,14 +265,13 @@ class _FakeAppServer {
if (delayCompletion) {
return;
}
socket.add(jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/completed',
'params': <String, dynamic>{
'threadId': threadId,
'turnId': 'turn-1',
},
}));
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'method': 'turn/completed',
'params': <String, dynamic>{'threadId': threadId, 'turnId': 'turn-1'},
}),
);
}
}
@ -282,11 +303,10 @@ Map<String, dynamic> _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,

View File

@ -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 <String>['codex', 'opencode', 'claude', 'gemini'],
);
});
test('round-trip preserves built-in entries and custom extensions', () {
final snapshot = SettingsSnapshot.defaults().copyWith(
externalAcpEndpoints: normalizeExternalAcpEndpoints(
profiles: <ExternalAcpEndpointProfile>[
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,
);
});
});
}