xworkmate-app/lib/app/app_controller_desktop_runtime_helpers.dart
2026-04-10 15:37:50 +08:00

824 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/embedded_agent_launch_policy.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/skill_directory_access.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_single_agent.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_actions.dart';
import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_coordination_impl.dart';
import 'app_controller_desktop_runtime_exceptions.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopRuntimeHelpers on AppController {
Future<void> persistAssistantLastSessionKeyInternal(String sessionKey) async {
if (disposedInternal) {
return;
}
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
if (normalizedSessionKey.isEmpty ||
settings.assistantLastSessionKey == normalizedSessionKey) {
return;
}
try {
await AppControllerDesktopSettings(this).saveSettings(
settings.copyWith(assistantLastSessionKey: normalizedSessionKey),
refreshAfterSave: false,
);
} catch (_) {
// Best effort only during teardown-sensitive transitions.
}
}
void setAiGatewayStreamingTextInternal(String sessionKey, String text) {
final key = normalizedAssistantSessionKeyInternal(sessionKey);
if (text.trim().isEmpty) {
aiGatewayStreamingTextBySessionInternal.remove(key);
} else {
aiGatewayStreamingTextBySessionInternal[key] = text;
}
notifyIfActiveInternal();
}
void appendAiGatewayStreamingTextInternal(String sessionKey, String delta) {
if (delta.isEmpty) {
return;
}
final key = normalizedAssistantSessionKeyInternal(sessionKey);
final current = aiGatewayStreamingTextBySessionInternal[key] ?? '';
aiGatewayStreamingTextBySessionInternal[key] = '$current$delta';
notifyIfActiveInternal();
}
void clearAiGatewayStreamingTextInternal(String sessionKey) {
final key = normalizedAssistantSessionKeyInternal(sessionKey);
if (aiGatewayStreamingTextBySessionInternal.remove(key) != null) {
notifyIfActiveInternal();
}
}
String nextLocalMessageIdInternal() {
localMessageCounterInternal += 1;
return 'local-${DateTime.now().microsecondsSinceEpoch}-$localMessageCounterInternal';
}
Future<T> enqueueThreadTurnInternal<T>(
String threadId,
Future<T> Function() task,
) {
final normalizedThreadId = normalizedAssistantSessionKeyInternal(threadId);
final previous =
assistantThreadTurnQueuesInternal[normalizedThreadId] ??
Future<void>.value();
final completer = Completer<T>();
late final Future<void> next;
next = previous
.catchError((_) {})
.then((_) async {
try {
completer.complete(await task());
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
})
.whenComplete(() {
if (identical(
assistantThreadTurnQueuesInternal[normalizedThreadId],
next,
)) {
assistantThreadTurnQueuesInternal.remove(normalizedThreadId);
}
});
assistantThreadTurnQueuesInternal[normalizedThreadId] = next;
return completer.future;
}
Uri? normalizeAiGatewayBaseUrlInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty);
return uri.replace(
pathSegments: pathSegments.isEmpty ? const <String>['v1'] : pathSegments,
query: null,
fragment: null,
);
}
Uri aiGatewayChatUriInternal(Uri baseUrl) {
final pathSegments = baseUrl.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.length >= 2 &&
pathSegments[pathSegments.length - 2] == 'chat' &&
pathSegments.last == 'completions') {
return baseUrl.replace(query: null, fragment: null);
}
if (pathSegments.last == 'models') {
pathSegments.removeLast();
}
if (pathSegments.last != 'chat') {
pathSegments.add('chat');
}
pathSegments.add('completions');
return baseUrl.replace(
pathSegments: pathSegments,
query: null,
fragment: null,
);
}
String aiGatewayHostLabelInternal(String raw) {
final uri = normalizeAiGatewayBaseUrlInternal(raw);
if (uri == null) {
return '';
}
if (uri.hasPort) {
return '${uri.host}:${uri.port}';
}
return uri.host;
}
String aiGatewayErrorLabelInternal(Object error) {
if (error is AiGatewayChatExceptionInternal) {
return error.message;
}
if (error is SocketException) {
return appText('无法连接到 LLM API。', 'Unable to reach the LLM API.');
}
if (error is HandshakeException) {
return appText('LLM API TLS 握手失败。', 'LLM API TLS handshake failed.');
}
if (error is TimeoutException) {
return appText('LLM API 请求超时。', 'LLM API request timed out.');
}
if (error is FormatException) {
return appText(
'LLM API 返回了无法解析的响应。',
'LLM API returned an invalid response.',
);
}
return error.toString();
}
String gatewayExecutionErrorLabelInternal(
Object error, {
required AssistantExecutionTarget target,
}) {
final raw = error.toString().trim();
final lowered = raw.toLowerCase();
if (lowered.contains('gateway not connected') ||
lowered.contains('code: offline') ||
lowered.contains('offlin') && lowered.contains('gateway')) {
if (target == AssistantExecutionTarget.singleAgent) {
final selection = singleAgentProviderForSession(
sessionsControllerInternal.currentSessionKey,
);
final provider =
resolvedSingleAgentProviderInternal(selection) ?? selection;
final providerLabel = provider == SingleAgentProvider.auto
? appText('Bridge Provider', 'Bridge Provider')
: provider.label;
final address = _extractGatewayAddressFromErrorInternal(raw);
return address.isEmpty
? appText(
'当前线程的 Bridge Provider$providerLabel)未连接。请先在设置里连接并同步后再重试。',
'The Bridge Provider for this thread ($providerLabel) is not connected. Connect and sync it from Settings, then try again.',
)
: appText(
'当前线程的 Bridge Provider$providerLabel)未连接:$address。请先在设置里连接并同步后再重试。',
'The Bridge Provider for this thread ($providerLabel) is not connected: $address. Connect and sync it from Settings, then try again.',
);
}
final profile = gatewayProfileForAssistantExecutionTargetInternal(target);
final address = gatewayAddressLabelInternal(profile);
final targetLabel = target.label;
return address == appText('未连接目标', 'No target')
? appText(
'当前线程目标网关未连接。请先连接 $targetLabel,然后再重试。',
'The selected gateway target for this thread is not connected. Connect $targetLabel first, then try again.',
)
: appText(
'当前线程目标网关未连接:$address。请先连接后再重试。',
'The selected gateway target for this thread is not connected: $address. Connect it first, then try again.',
);
}
return raw;
}
String _extractGatewayAddressFromErrorInternal(String raw) {
final match = RegExp(
r'((?:\d{1,3}\.){3}\d{1,3}:\d+|localhost:\d+|[a-zA-Z0-9.-]+:\d+)',
).firstMatch(raw);
return match?.group(1)?.trim() ?? '';
}
String formatAiGatewayHttpErrorInternal(int statusCode, String detail) {
final base = switch (statusCode) {
400 => appText(
'LLM API 请求无效 (400)',
'LLM API rejected the request (400)',
),
401 => appText(
'LLM API 鉴权失败 (401)',
'LLM API authentication failed (401)',
),
403 => appText('LLM API 拒绝访问 (403)', 'LLM API denied access (403)'),
404 => appText(
'LLM API chat 接口不存在 (404)',
'LLM API chat endpoint was not found (404)',
),
429 => appText(
'LLM API 限流 (429)',
'LLM API rate limited the request (429)',
),
>= 500 => appText(
'LLM API 当前不可用 ($statusCode)',
'LLM API is unavailable right now ($statusCode)',
),
_ => appText(
'LLM API 返回状态码 $statusCode',
'LLM API responded with status $statusCode',
),
};
final trimmed = detail.trim();
return trimmed.isEmpty ? base : '$base · $trimmed';
}
String extractAiGatewayErrorDetailInternal(String body) {
if (body.trim().isEmpty) {
return '';
}
try {
final decoded = jsonDecode(extractFirstJsonDocumentInternal(body));
final map = asMap(decoded);
final error = asMap(map['error']);
return (stringValue(error['message']) ??
stringValue(map['message']) ??
stringValue(map['detail']) ??
'')
.trim();
} on FormatException {
return '';
}
}
String extractAiGatewayAssistantTextInternal(Object? decoded) {
final map = asMap(decoded);
final choices = asList(map['choices']);
if (choices.isNotEmpty) {
final firstChoice = asMap(choices.first);
final message = asMap(firstChoice['message']);
final content = extractAiGatewayContentInternal(message['content']);
if (content.isNotEmpty) {
return content;
}
}
final output = asList(map['output']);
for (final item in output) {
final entry = asMap(item);
final content = extractAiGatewayContentInternal(entry['content']);
if (content.isNotEmpty) {
return content;
}
}
final direct = extractAiGatewayContentInternal(map['content']);
if (direct.isNotEmpty) {
return direct;
}
return stringValue(map['output_text'])?.trim() ?? '';
}
String extractAiGatewayContentInternal(Object? content) {
if (content is String) {
return content.trim();
}
final parts = <String>[];
for (final item in asList(content)) {
final map = asMap(item);
final nestedText = stringValue(map['text']);
if (nestedText != null && nestedText.trim().isNotEmpty) {
parts.add(nestedText.trim());
continue;
}
final type = stringValue(map['type']) ?? '';
if (type == 'output_text') {
final text = stringValue(map['text']) ?? stringValue(map['value']);
if (text != null && text.trim().isNotEmpty) {
parts.add(text.trim());
}
}
}
return parts.join('\n').trim();
}
String extractFirstJsonDocumentInternal(String body) {
final trimmed = body.trimLeft();
if (trimmed.isEmpty) {
throw const FormatException('Empty response body');
}
final start = trimmed.indexOf(RegExp(r'[\{\[]'));
if (start < 0) {
throw const FormatException('Missing JSON document');
}
var depth = 0;
var inString = false;
var escaped = false;
for (var index = start; index < trimmed.length; index++) {
final char = trimmed[index];
if (escaped) {
escaped = false;
continue;
}
if (char == r'\') {
escaped = true;
continue;
}
if (char == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char == '{' || char == '[') {
depth += 1;
} else if (char == '}' || char == ']') {
depth -= 1;
if (depth == 0) {
return trimmed.substring(start, index + 1);
}
}
}
throw const FormatException('Unterminated JSON document');
}
SettingsSnapshot sanitizeCodeAgentSettingsInternal(
SettingsSnapshot snapshot,
) {
final normalizedRuntimeMode =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? CodeAgentRuntimeMode.externalCli
: snapshot.codeAgentRuntimeMode;
codexRuntimeWarningInternal =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? appText(
'内置 Codex 运行时当前仅保留为未来扩展位;已自动切换为 External Codex CLI。',
'Built-in Codex runtime is reserved for a future release; XWorkmate switched back to External Codex CLI automatically.',
)
: null;
final normalizedPath = snapshot.codexCliPath.trim();
if (normalizedPath == snapshot.codexCliPath &&
normalizedRuntimeMode == snapshot.codeAgentRuntimeMode) {
return snapshot;
}
return snapshot.copyWith(
codeAgentRuntimeMode: normalizedRuntimeMode,
codexCliPath: normalizedPath,
);
}
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) => refreshAcpCapabilitiesRuntimeInternal(
this,
forceRefresh: forceRefresh,
persistMountTargets: persistMountTargets,
);
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) => refreshSingleAgentCapabilitiesRuntimeInternal(
this,
forceRefresh: forceRefresh,
);
Future<void> refreshResolvedCodexCliPathInternal() async {
if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) {
resolvedCodexCliPathInternal = null;
return;
}
if (shouldBlockEmbeddedAgentLaunch(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
resolvedCodexCliPathInternal = null;
return;
}
final configuredPath = configuredCodexCliPath;
String? detectedPath;
if (configuredPath.isNotEmpty) {
try {
if (await File(configuredPath).exists()) {
detectedPath = configuredPath;
}
} catch (_) {
detectedPath = null;
}
}
detectedPath ??= await runtimeCoordinatorInternal.codex.findCodexBinary();
if (disposedInternal) {
return;
}
resolvedCodexCliPathInternal = detectedPath;
}
List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsInternal(
List<ManagedMountTargetState> current,
GatewayAcpCapabilities capabilities,
) => mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
this,
current,
capabilities,
);
String? assistantWorkingDirectoryForSessionInternal(String sessionKey) =>
assistantWorkingDirectoryForSessionRuntimeInternal(this, sessionKey);
String? resolveLocalAssistantWorkingDirectoryForSessionInternal(
String sessionKey, {
bool requireLocalExistence = true,
}) => resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
this,
sessionKey,
requireLocalExistence: requireLocalExistence,
);
String? resolveSingleAgentWorkingDirectoryForSessionInternal(
String sessionKey, {
SingleAgentProvider? provider,
}) => resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal(
this,
sessionKey,
provider: provider,
);
bool singleAgentProviderRequiresLocalPathInternal(
SingleAgentProvider provider,
) => singleAgentProviderRequiresLocalPathRuntimeInternal(this, provider);
void registerCodexExternalProviderInternal() {
runtimeCoordinatorInternal.registerExternalCodeAgent(
ExternalCodeAgentProvider(
id: 'codex',
name: 'Codex ACP',
command: 'xworkmate-agent-gateway',
transport: ExternalAgentTransport.websocketJsonRpc,
endpoint: '',
defaultArgs: const <String>[],
capabilities: const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
'single-agent',
'multi-agent',
],
),
);
}
CodeAgentNodeState buildCodeAgentNodeStateInternal() =>
buildCodeAgentNodeStateRuntimeInternal(this);
GatewayMode bridgeGatewayModeInternal() =>
bridgeGatewayModeRuntimeInternal(this);
Future<void> ensureCodexGatewayRegistrationInternal() =>
ensureCodexGatewayRegistrationRuntimeInternal(this);
void clearCodexGatewayRegistrationInternal() =>
clearCodexGatewayRegistrationRuntimeInternal(this);
void recomputeTasksInternal() => recomputeTasksRuntimeInternal(this);
void attachChildListenersInternal() {
runtimeCoordinatorInternal.addListener(relayChildChangeInternal);
settingsControllerInternal.addListener(
handleSettingsControllerChangeInternal,
);
agentsControllerInternal.addListener(relayChildChangeInternal);
sessionsControllerInternal.addListener(relayChildChangeInternal);
chatControllerInternal.addListener(relayChildChangeInternal);
instancesControllerInternal.addListener(relayChildChangeInternal);
skillsControllerInternal.addListener(relayChildChangeInternal);
connectorsControllerInternal.addListener(relayChildChangeInternal);
modelsControllerInternal.addListener(relayChildChangeInternal);
cronJobsControllerInternal.addListener(relayChildChangeInternal);
devicesControllerInternal.addListener(relayChildChangeInternal);
tasksControllerInternal.addListener(relayChildChangeInternal);
multiAgentOrchestratorInternal.addListener(relayChildChangeInternal);
}
void detachChildListenersInternal() {
runtimeCoordinatorInternal.removeListener(relayChildChangeInternal);
settingsControllerInternal.removeListener(
handleSettingsControllerChangeInternal,
);
agentsControllerInternal.removeListener(relayChildChangeInternal);
sessionsControllerInternal.removeListener(relayChildChangeInternal);
chatControllerInternal.removeListener(relayChildChangeInternal);
instancesControllerInternal.removeListener(relayChildChangeInternal);
skillsControllerInternal.removeListener(relayChildChangeInternal);
connectorsControllerInternal.removeListener(relayChildChangeInternal);
modelsControllerInternal.removeListener(relayChildChangeInternal);
cronJobsControllerInternal.removeListener(relayChildChangeInternal);
devicesControllerInternal.removeListener(relayChildChangeInternal);
tasksControllerInternal.removeListener(relayChildChangeInternal);
multiAgentOrchestratorInternal.removeListener(relayChildChangeInternal);
}
void handleSettingsControllerChangeInternal() {
final previous = lastObservedSettingsSnapshotInternal;
final current = settings;
final previousJson = previous.toJsonString();
final currentJson = current.toJsonString();
if (currentJson == previousJson) {
notifyIfActiveInternal();
return;
}
final hadDraftChanges =
settingsDraftInitializedInternal &&
(settingsDraftInternal.toJsonString() != previousJson ||
draftSecretValuesInternal.isNotEmpty);
if (!settingsDraftInitializedInternal || !hadDraftChanges) {
settingsDraftInternal = current;
settingsDraftInitializedInternal = true;
settingsDraftStatusMessageInternal = '';
}
lastObservedSettingsSnapshotInternal = current;
settingsObservationQueueInternal = settingsObservationQueueInternal
.then((_) async {
await handleObservedSettingsChangeInternal(
previous: previous,
current: current,
);
})
.catchError((_) {});
notifyIfActiveInternal();
}
Future<void> handleObservedSettingsChangeInternal({
required SettingsSnapshot previous,
required SettingsSnapshot current,
}) async {
if (disposedInternal) {
return;
}
setActiveAppLanguage(current.appLanguage);
multiAgentOrchestratorInternal.updateConfig(current.multiAgent);
if (previous.codexCliPath != current.codexCliPath ||
previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) {
await refreshResolvedCodexCliPathInternal();
registerCodexExternalProviderInternal();
if (disposedInternal) {
return;
}
}
if (authorizedSkillDirectoriesChangedInternal(previous, current)) {
await refreshSharedSingleAgentLocalSkillsCacheInternal(forceRescan: true);
if (disposedInternal) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
notifyIfActiveInternal();
}
void relayChildChangeInternal() {
notifyIfActiveInternal();
}
void notifyIfActiveInternal() {
if (disposedInternal) {
return;
}
notifyListeners();
}
Uri? resolveSingleAgentEndpointInternal(SingleAgentProvider provider) {
final endpoint = settings
.externalAcpEndpointForProvider(provider)
.endpoint
.trim();
if (endpoint.isEmpty) {
return null;
}
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;
}
Future<String> resolveSingleAgentAuthorizationHeaderInternal(
Uri endpoint,
) async {
final normalizedEndpoint = _normalizeExternalAcpEndpointInternal(
endpoint.toString(),
);
if (normalizedEndpoint == null) {
return '';
}
for (final profile in settings.externalAcpEndpoints) {
final profileEndpoint = _normalizeExternalAcpEndpointInternal(
profile.endpoint,
);
if (profileEndpoint == null || profileEndpoint != normalizedEndpoint) {
continue;
}
final authRef = profile.authRef.trim();
if (authRef.isEmpty) {
return '';
}
return settingsControllerInternal.resolveSecretValueInternal(
refName: authRef,
);
}
return '';
}
Future<String> resolveSingleAgentAuthorizationHeaderForProviderInternal(
SingleAgentProvider provider,
) async {
final endpoint = resolveSingleAgentEndpointInternal(provider);
if (endpoint == null) {
return '';
}
return resolveSingleAgentAuthorizationHeaderInternal(endpoint);
}
Uri? resolveGatewayAcpEndpointInternal() {
final target = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
);
if (target == AssistantExecutionTarget.singleAgent) {
return null;
}
return gatewayProfileBaseUriInternal(
gatewayProfileForAssistantExecutionTargetInternal(target),
);
}
Uri? resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget target,
) {
if (target == AssistantExecutionTarget.singleAgent) {
return null;
}
return gatewayProfileBaseUriInternal(
gatewayProfileForAssistantExecutionTargetInternal(target),
);
}
Uri? gatewayProfileBaseUriInternal(GatewayConnectionProfile profile) {
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return null;
}
return Uri(
scheme: profile.tls ? 'https' : 'http',
host: host,
port: profile.port,
);
}
RuntimeConnectionMode modeFromHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
if (isLoopbackHostInternal(trimmed)) {
return RuntimeConnectionMode.local;
}
return RuntimeConnectionMode.remote;
}
bool isLoopbackHostInternal(String host) {
final trimmed = host.trim().toLowerCase();
return trimmed == '127.0.0.1' || trimmed == 'localhost';
}
String? _normalizeExternalAcpEndpointInternal(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = trimmed.contains('://') ? trimmed : 'ws://$trimmed';
final uri = Uri.tryParse(candidate);
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;
}
final defaultPort = switch (scheme) {
'https' || 'wss' => 443,
_ => 80,
};
final port = uri.hasPort ? uri.port : defaultPort;
final path = uri.path.trim().isEmpty ? '/' : uri.path.trim();
return '$scheme://${uri.host.toLowerCase()}:$port$path';
}
AssistantExecutionTarget assistantExecutionTargetForModeInternal(
RuntimeConnectionMode mode,
) {
return switch (mode) {
RuntimeConnectionMode.unconfigured =>
AssistantExecutionTarget.singleAgent,
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
};
}
GatewayConnectionProfile gatewayProfileForAssistantExecutionTargetInternal(
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.local => settings.primaryLocalGatewayProfile,
AssistantExecutionTarget.remote => settings.primaryRemoteGatewayProfile,
AssistantExecutionTarget.singleAgent => throw StateError(
'Single Agent target has no OpenClaw gateway profile.',
),
};
}
int gatewayProfileIndexForExecutionTargetInternal(
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => throw StateError(
'Single Agent target has no OpenClaw gateway profile index.',
),
};
}
}