From 93032366bd6854925824675a3e548c1c484519d3 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 17:07:27 +0800 Subject: [PATCH] Integrate gateway settings into integrations page --- dart_test.yaml | 1 + lib/app/app_controller_desktop.dart | 102 +- lib/app/app_controller_web.dart | 37 + lib/features/assistant/assistant_page.dart | 20 +- lib/features/mobile/mobile_shell.dart | 23 +- lib/features/settings/settings_page.dart | 1226 ++++++++++++----- lib/runtime/runtime_controllers.dart | 41 +- lib/widgets/gateway_connect_dialog.dart | 760 ---------- test/features/ai_gateway_page_suite.dart | 27 +- test/features/assistant_page_suite.dart | 6 +- ...settings_ai_gateway_persistence_suite.dart | 111 +- test/features/settings_page_suite.dart | 9 +- .../widgets/gateway_connect_dialog_suite.dart | 66 - test/widgets/gateway_connect_dialog_test.dart | 7 - 14 files changed, 1161 insertions(+), 1275 deletions(-) create mode 100644 dart_test.yaml delete mode 100644 lib/widgets/gateway_connect_dialog.dart delete mode 100644 test/widgets/gateway_connect_dialog_suite.dart delete mode 100644 test/widgets/gateway_connect_dialog_test.dart diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 00000000..6d8e711b --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1 @@ +concurrency: 1 diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index c745c5d7..5b127bb4 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -86,7 +86,10 @@ class AppController extends ChangeNotifier { _desktopPlatformService = desktopPlatformService ?? createDesktopPlatformService(); _gatewayOnlySkillScanRoots = - gatewayOnlySkillScanRoots ?? _defaultGatewayOnlySkillScanRoots; + gatewayOnlySkillScanRoots ?? + (_isFlutterTestEnvironment + ? const [] + : _defaultGatewayOnlySkillScanRoots); _arisBundleRepository = ArisBundleRepository(); _arisBridgeLocator = ArisBridgeLocator(); _multiAgentMountManager = MultiAgentMountManager( @@ -169,6 +172,9 @@ class AppController extends ChangeNotifier { String? _bootstrapError; StreamSubscription? _runtimeEventsSubscription; bool _disposed = false; + + static bool get _isFlutterTestEnvironment => + Platform.environment.containsKey('FLUTTER_TEST'); Future _assistantThreadPersistQueue = Future.value(); WorkspaceDestination get destination => _destination; @@ -2077,10 +2083,91 @@ class AppController extends ChangeNotifier { return _settingsController.testOllamaConnection(cloud: cloud); } + Future testOllamaConnectionDraft({ + required bool cloud, + required SettingsSnapshot snapshot, + String apiKeyOverride = '', + }) { + return _settingsController.testOllamaConnectionDraft( + cloud: cloud, + localConfig: snapshot.ollamaLocal, + cloudConfig: snapshot.ollamaCloud, + apiKeyOverride: apiKeyOverride, + ); + } + Future testVaultConnection() { return _settingsController.testVaultConnection(); } + Future testVaultConnectionDraft({ + required SettingsSnapshot snapshot, + String tokenOverride = '', + }) { + return _settingsController.testVaultConnectionDraft( + snapshot.vault, + tokenOverride: tokenOverride, + ); + } + + Future<({String state, String message, String endpoint})> + testGatewayConnectionDraft({ + required GatewayConnectionProfile profile, + required AssistantExecutionTarget executionTarget, + String tokenOverride = '', + String passwordOverride = '', + }) async { + if (executionTarget == AssistantExecutionTarget.aiGatewayOnly || + profile.mode == RuntimeConnectionMode.unconfigured) { + return ( + state: 'inactive', + message: appText( + '当前模式仅使用 AI Gateway,不建立 OpenClaw Gateway 会话。', + 'The current mode uses AI Gateway only and does not open an OpenClaw Gateway session.', + ), + endpoint: '', + ); + } + + final runtime = GatewayRuntime( + store: _store, + identityStore: DeviceIdentityStore(_store), + ); + await runtime.initialize(); + try { + await runtime.connectProfile( + profile, + authTokenOverride: tokenOverride, + authPasswordOverride: passwordOverride, + ); + try { + await runtime.health(); + } catch (_) { + // Connectivity succeeded; health is best-effort for the test path. + } + final endpoint = runtime.snapshot.remoteAddress ?? + '${profile.host}:${profile.port}'; + return ( + state: 'success', + message: appText('连接成功。', 'Connection succeeded.'), + endpoint: endpoint, + ); + } catch (error) { + return ( + state: 'error', + message: error.toString(), + endpoint: '${profile.host}:${profile.port}', + ); + } finally { + try { + await runtime.disconnect(clearDesiredProfile: false); + } catch (_) { + // Ignore teardown noise from temporary connectivity checks. + } + runtime.dispose(); + } + } + void clearRuntimeLogs() { _runtimeCoordinator.gateway.clearLogs(); _notifyIfActive(); @@ -2274,7 +2361,10 @@ class AppController extends ChangeNotifier { // Keep the shell usable when auto-connect fails. } } - await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync); + // Mount reconciliation may invoke multiple external CLIs. Keep startup + // responsive and let the mounts refresh in the background instead of + // blocking app initialization on those probes. + unawaited(refreshMultiAgentMounts(sync: settings.multiAgent.autoSync)); _settingsDraft = settings; _lastAppliedSettings = settings; _settingsDraftInitialized = true; @@ -3337,7 +3427,13 @@ class AppController extends ChangeNotifier { if (_disposed) { return; } - await _store.saveAssistantThreadRecords(snapshot); + try { + await _store.saveAssistantThreadRecords(snapshot); + } catch (_) { + // Assistant thread persistence is background best-effort. Keep the + // in-memory session usable even when teardown or temp-directory + // cleanup races with the durable write. + } }); _assistantThreadPersistQueue = nextPersist; unawaited(nextPersist); diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index b7a36f3d..7e4397ab 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -426,6 +426,43 @@ class AppController extends ChangeNotifier { _saveSecretDraft(_draftOllamaApiKeyKey, value); } + Future testOllamaConnection({required bool cloud}) async { + return cloud ? 'Cloud test unavailable on web' : 'Local test unavailable on web'; + } + + Future testOllamaConnectionDraft({ + required bool cloud, + required SettingsSnapshot snapshot, + String apiKeyOverride = '', + }) async { + return testOllamaConnection(cloud: cloud); + } + + Future testVaultConnection() async { + return 'Vault test unavailable on web'; + } + + Future testVaultConnectionDraft({ + required SettingsSnapshot snapshot, + String tokenOverride = '', + }) async { + return testVaultConnection(); + } + + Future<({String state, String message, String endpoint})> + testGatewayConnectionDraft({ + required GatewayConnectionProfile profile, + required AssistantExecutionTarget executionTarget, + String tokenOverride = '', + String passwordOverride = '', + }) async { + return ( + state: 'unsupported', + message: 'Gateway test unavailable on web', + endpoint: '', + ); + } + Future persistSettingsDraft() async { if (!hasSettingsDraftChanges) { _settingsDraftStatusMessage = appText( diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 939eb63b..7c0e45bd 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -18,7 +18,6 @@ import '../../runtime/runtime_models.dart'; import '../../theme/app_palette.dart'; import '../../theme/app_theme.dart'; import '../../widgets/assistant_focus_panel.dart'; -import '../../widgets/gateway_connect_dialog.dart'; import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/pane_resize_handle.dart'; import '../../widgets/surface_card.dart'; @@ -401,7 +400,7 @@ class _AssistantPageState extends State { scrollController: _conversationController, onOpenDetail: widget.onOpenDetail, onFocusComposer: _focusComposer, - onOpenGateway: _showConnectDialog, + onOpenGateway: _openGatewaySettings, onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onMessageViewModeChanged: @@ -463,7 +462,7 @@ class _AssistantPageState extends State { controller.currentSessionKey, modelId, ), - onOpenGateway: _showConnectDialog, + onOpenGateway: _openGatewaySettings, onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onPickAttachments: _pickAttachments, @@ -878,19 +877,20 @@ class _AssistantPageState extends State { }; } - void _showConnectDialog() { - showDialog( - context: context, - builder: (context) => GatewayConnectDialog( - controller: widget.controller, - onDone: () => Navigator.of(context).pop(), + void _openGatewaySettings() { + widget.controller.openSettings( + detail: SettingsDetailPage.gatewayConnection, + navigationContext: SettingsNavigationContext( + rootLabel: appText('助手', 'Assistant'), + destination: WorkspaceDestination.assistant, + sectionLabel: appText('集成', 'Integrations'), ), ); } Future _connectFromSavedSettingsOrShowDialog() async { if (!widget.controller.canQuickConnectGateway) { - _showConnectDialog(); + _openGatewaySettings(); return; } await widget.controller.connectSavedGateway(); diff --git a/lib/features/mobile/mobile_shell.dart b/lib/features/mobile/mobile_shell.dart index 72f95ff3..589e5a57 100644 --- a/lib/features/mobile/mobile_shell.dart +++ b/lib/features/mobile/mobile_shell.dart @@ -11,7 +11,6 @@ import '../../runtime/runtime_models.dart'; import '../../theme/app_palette.dart'; import '../../theme/app_theme.dart'; import '../../widgets/detail_drawer.dart'; -import '../../widgets/gateway_connect_dialog.dart'; enum MobileShellTab { assistant, tasks, workspace, secrets, settings } @@ -147,19 +146,13 @@ class _MobileShellState extends State { } void _showConnectSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return FractionallySizedBox( - heightFactor: 0.94, - child: GatewayConnectDialog( - controller: widget.controller, - onDone: () => Navigator.of(sheetContext).pop(), - ), - ); - }, + widget.controller.openSettings( + detail: SettingsDetailPage.gatewayConnection, + navigationContext: SettingsNavigationContext( + rootLabel: appText('移动端', 'Mobile'), + destination: WorkspaceDestination.settings, + sectionLabel: appText('集成', 'Integrations'), + ), ); } @@ -689,7 +682,7 @@ class _MobileSafeSheet extends StatelessWidget { child: Text( controller.canQuickConnectGateway ? appText('快速连接', 'Quick Connect') - : appText('打开连接面板', 'Open Connection'), + : appText('打开集成设置', 'Open Integrations'), ), ), if (hasPendingRun) diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 973d69c9..1c6e8a7f 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -9,9 +9,9 @@ import '../../app/workspace_navigation.dart'; import '../ai_gateway/ai_gateway_page.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; +import '../../runtime/gateway_runtime.dart'; import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; -import '../../widgets/gateway_connect_dialog.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; @@ -45,9 +45,24 @@ class _SettingsPageState extends State { late final TextEditingController _aiGatewayApiKeyRefController; late final TextEditingController _aiGatewayApiKeyController; late final TextEditingController _aiGatewayModelSearchController; + late final TextEditingController _gatewaySetupCodeController; + late final TextEditingController _gatewayHostController; + late final TextEditingController _gatewayPortController; + late final TextEditingController _gatewayTokenController; + late final TextEditingController _gatewayPasswordController; late final TextEditingController _vaultTokenController; late final TextEditingController _ollamaApiKeyController; late final TextEditingController _runtimeLogFilterController; + bool _gatewayTesting = false; + String _gatewayTestState = 'idle'; + String _gatewayTestMessage = ''; + String _gatewayTestEndpoint = ''; + String _gatewaySetupCodeSyncedValue = ''; + String _gatewayHostSyncedValue = ''; + String _gatewayPortSyncedValue = ''; + RuntimeConnectionMode? _gatewayDraftMode; + bool? _gatewayDraftUseSetupCode; + bool? _gatewayDraftTls; bool _aiGatewayTesting = false; String _aiGatewayTestState = 'idle'; String _aiGatewayTestMessage = ''; @@ -70,6 +85,11 @@ class _SettingsPageState extends State { _aiGatewayApiKeyRefController = TextEditingController(); _aiGatewayApiKeyController = TextEditingController(); _aiGatewayModelSearchController = TextEditingController(); + _gatewaySetupCodeController = TextEditingController(); + _gatewayHostController = TextEditingController(); + _gatewayPortController = TextEditingController(); + _gatewayTokenController = TextEditingController(); + _gatewayPasswordController = TextEditingController(); _vaultTokenController = TextEditingController(); _ollamaApiKeyController = TextEditingController(); _runtimeLogFilterController = TextEditingController(); @@ -96,6 +116,11 @@ class _SettingsPageState extends State { _aiGatewayApiKeyRefController.dispose(); _aiGatewayApiKeyController.dispose(); _aiGatewayModelSearchController.dispose(); + _gatewaySetupCodeController.dispose(); + _gatewayHostController.dispose(); + _gatewayPortController.dispose(); + _gatewayTokenController.dispose(); + _gatewayPasswordController.dispose(); _vaultTokenController.dispose(); _ollamaApiKeyController.dispose(); _runtimeLogFilterController.dispose(); @@ -224,7 +249,6 @@ class _SettingsPageState extends State { SettingsSnapshot settings, SettingsDetailPage detail, ) { - final gatewaySections = _buildGateway(context, controller, settings); final workspaceSections = _buildWorkspace(context, controller, settings); return switch (detail) { SettingsDetailPage.gatewayConnection => [ @@ -237,7 +261,11 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), - ...gatewaySections.take(3), + _buildOpenClawGatewayCard(context, controller, settings), + const SizedBox(height: 16), + _buildVaultProviderCard(context, controller, settings), + const SizedBox(height: 16), + _buildAiGatewayCard(context, controller, settings), ], SettingsDetailPage.aiGatewayIntegration => [ _buildDetailIntro( @@ -249,7 +277,7 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), - if (gatewaySections.isNotEmpty) gatewaySections.last, + _buildAiGatewayCard(context, controller, settings), ], SettingsDetailPage.vaultProvider => [ _buildDetailIntro( @@ -261,7 +289,7 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), - if (gatewaySections.length > 4) gatewaySections[4], + _buildVaultProviderCard(context, controller, settings), ], SettingsDetailPage.ollamaProvider => [ _buildDetailIntro( @@ -857,6 +885,400 @@ class _SettingsPageState extends State { BuildContext context, AppController controller, SettingsSnapshot settings, + ) { + return [ + _buildOpenClawGatewayCard(context, controller, settings), + const SizedBox(height: 16), + _buildVaultProviderCard(context, controller, settings), + const SizedBox(height: 16), + _buildAiGatewayCard(context, controller, settings), + const SizedBox(height: 16), + _buildDeviceSecurityCard(context, controller), + ]; + } + + Widget _buildOpenClawGatewayCard( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + _syncGatewayDraftControllers(settings); + final theme = Theme.of(context); + final gatewayMode = _gatewayDraftMode ?? settings.gateway.mode; + final useSetupCode = _gatewayDraftUseSetupCode ?? settings.gateway.useSetupCode; + final gatewayTls = gatewayMode == RuntimeConnectionMode.local + ? false + : (_gatewayDraftTls ?? settings.gateway.tls); + final hasStoredGatewayToken = controller.hasStoredGatewayToken; + final hasStoredGatewayPassword = + controller.settingsController.secureRefs['gateway_password'] != null; + final typedGatewayToken = _gatewayTokenController.text.trim(); + final willUseStoredGatewayToken = + typedGatewayToken.isEmpty && hasStoredGatewayToken; + final showSharedTokenStatusCard = + gatewayMode != RuntimeConnectionMode.unconfigured && + (willUseStoredGatewayToken || typedGatewayToken.isNotEmpty); + final connectionDescription = controller.connection.remoteAddress ?? + '${settings.gateway.host}:${settings.gateway.port}'; + final gatewayTarget = _assistantExecutionTargetForMode(gatewayMode); + + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'OpenClaw Gateway', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + '统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存只持久化,应用才会按当前模式发起连接或切换为仅 AI Gateway。', + 'Edit local and remote OpenClaw gateway settings in one place. Save persists only; Apply connects or switches to AI Gateway-only mode.', + ), + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + _buildNotice( + context, + tone: Theme.of(context).colorScheme.surfaceContainerHighest, + title: controller.connection.status.label, + message: + '$connectionDescription\n${appText('认证诊断', 'Auth Diagnostics')}\n${controller.connection.connectAuthSummary}', + ), + const SizedBox(height: 16), + DropdownButtonFormField( + key: const ValueKey('gateway-mode-field'), + initialValue: gatewayMode, + decoration: InputDecoration( + labelText: appText('工作模式', 'Work Mode'), + ), + items: const [ + RuntimeConnectionMode.unconfigured, + RuntimeConnectionMode.local, + RuntimeConnectionMode.remote, + ] + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(_connectionModeLabel(mode)), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _gatewayDraftMode = value; + if (value == RuntimeConnectionMode.local) { + _gatewayDraftUseSetupCode = false; + _gatewayDraftTls = false; + _gatewayHostController.text = '127.0.0.1'; + _gatewayPortController.text = '18789'; + } else if (value == RuntimeConnectionMode.unconfigured) { + _gatewayDraftUseSetupCode = false; + } else { + _gatewayDraftTls ??= true; + } + }); + unawaited(_saveGatewayDraft(controller, settings).catchError((_) {})); + }, + ), + if (gatewayMode != RuntimeConnectionMode.unconfigured) ...[ + const SizedBox(height: 12), + SectionTabs( + items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')], + value: useSetupCode + ? appText('配置码', 'Setup Code') + : appText('手动配置', 'Manual'), + size: SectionTabsSize.small, + onChanged: (value) { + setState(() { + _gatewayDraftUseSetupCode = + value == appText('配置码', 'Setup Code'); + }); + unawaited( + _saveGatewayDraft(controller, settings).catchError((_) {}), + ); + }, + ), + const SizedBox(height: 12), + if (useSetupCode) ...[ + TextField( + key: const ValueKey('gateway-setup-code-field'), + controller: _gatewaySetupCodeController, + minLines: 4, + maxLines: 6, + decoration: InputDecoration( + labelText: appText('配置码', 'Setup Code'), + hintText: appText( + '粘贴 Gateway 配置码或 JSON 负载', + 'Paste gateway setup code or JSON payload', + ), + ), + onChanged: (_) => unawaited( + _saveGatewayDraft(controller, settings).catchError((_) {}), + ), + onSubmitted: (_) => _saveGatewayDraft(controller, settings), + ), + ] else ...[ + TextField( + key: const ValueKey('gateway-host-field'), + controller: _gatewayHostController, + decoration: InputDecoration( + labelText: appText('主机', 'Host'), + ), + onChanged: (_) => unawaited( + _saveGatewayDraft(controller, settings).catchError((_) {}), + ), + onSubmitted: (_) => _saveGatewayDraft(controller, settings), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: TextField( + key: const ValueKey('gateway-port-field'), + controller: _gatewayPortController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: appText('端口', 'Port'), + ), + onChanged: (_) => unawaited( + _saveGatewayDraft(controller, settings).catchError((_) {}), + ), + onSubmitted: (_) => _saveGatewayDraft(controller, settings), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: Opacity( + opacity: + gatewayMode == RuntimeConnectionMode.local ? 0.6 : 1, + child: _InlineSwitchField( + label: 'TLS', + value: gatewayTls, + onChanged: (value) { + if (gatewayMode == RuntimeConnectionMode.local) { + return; + } + setState(() => _gatewayDraftTls = value); + unawaited( + _saveGatewayDraft(controller, settings) + .catchError((_) {}), + ); + }, + ), + ), + ), + ], + ), + ], + const SizedBox(height: 16), + TextField( + key: const ValueKey('gateway-shared-token-field'), + controller: _gatewayTokenController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + labelText: appText('共享 Token', 'Shared Token'), + hintText: appText( + '可选:覆盖默认 Gateway Token', + 'Optional override for gateway token', + ), + ), + onChanged: (_) => controller.saveGatewayTokenDraft( + _gatewayTokenController.text, + ), + ), + if (showSharedTokenStatusCard) ...[ + const SizedBox(height: 10), + _GatewaySecretStatusCard( + message: willUseStoredGatewayToken + ? appText( + '已安全保存 shared token(${controller.storedGatewayTokenMask})。留空时会直接使用它连接。', + 'A shared token is already stored securely (${controller.storedGatewayTokenMask}). Leave the field empty to connect with it.', + ) + : appText( + '本次输入会覆盖已安全保存的 shared token。', + 'This entry will overwrite the stored shared token.', + ), + locked: hasStoredGatewayToken, + onClear: hasStoredGatewayToken + ? () async { + await controller.clearStoredGatewayToken(); + if (mounted) { + setState(() {}); + } + } + : null, + ), + ], + const SizedBox(height: 12), + TextField( + key: const ValueKey('gateway-password-field'), + controller: _gatewayPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('密码', 'Password'), + hintText: appText('可选:共享密码', 'Optional shared password'), + helperText: hasStoredGatewayPassword + ? appText( + '已存在安全保存的密码;输入新值后会在保存时覆盖。', + 'A password is already stored securely; entering a new value replaces it on Save.', + ) + : appText( + '输入后先进入草稿;保存后才会写入安全存储。', + 'Values stage into draft first and only persist after Save.', + ), + ), + onChanged: (_) => controller.saveGatewayPasswordDraft( + _gatewayPasswordController.text, + ), + ), + ] else ...[ + const SizedBox(height: 12), + Text( + appText( + '当前模式仅通过 AI Gateway 处理任务,不会建立 OpenClaw Gateway 会话。', + 'This mode routes tasks through AI Gateway only and does not establish an OpenClaw Gateway session.', + ), + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 16), + _buildSettingsSectionActions( + controller: controller, + testKey: const ValueKey('gateway-test-button'), + saveKey: const ValueKey('gateway-save-button'), + applyKey: const ValueKey('gateway-apply-button'), + testing: _gatewayTesting, + onTest: () => _testGatewayConnection( + controller, + settings, + executionTarget: gatewayTarget, + ), + onSave: () => _saveGatewayAndPersist(controller, settings), + onApply: () => _saveGatewayAndApply(controller, settings), + ), + if (_gatewayTestMessage.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildNotice( + context, + tone: _gatewayTestState == 'success' + ? Theme.of(context).colorScheme.secondaryContainer + : Theme.of(context).colorScheme.errorContainer, + title: appText('测试连接', 'Test Connection'), + message: _gatewayTestEndpoint.isEmpty + ? _gatewayTestMessage + : '$_gatewayTestMessage\n$_gatewayTestEndpoint', + ), + ], + ], + ), + ); + } + + Widget _buildVaultProviderCard( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + final hasStoredVaultToken = + controller.settingsController.secureRefs['vault_token'] != null; + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('Vault Server', 'Vault Server'), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + _EditableField( + label: appText('地址', 'Address'), + value: settings.vault.address, + onSubmitted: (value) => _saveSettings( + controller, + settings.copyWith( + vault: settings.vault.copyWith(address: value), + ), + ), + ), + _EditableField( + label: appText('命名空间', 'Namespace'), + value: settings.vault.namespace, + onSubmitted: (value) => _saveSettings( + controller, + settings.copyWith( + vault: settings.vault.copyWith(namespace: value), + ), + ), + ), + _EditableField( + label: appText('认证模式', 'Auth Mode'), + value: settings.vault.authMode, + onSubmitted: (value) => _saveSettings( + controller, + settings.copyWith( + vault: settings.vault.copyWith(authMode: value), + ), + ), + ), + _EditableField( + label: appText('Token 引用', 'Token Ref'), + value: settings.vault.tokenRef, + onSubmitted: (value) => _saveSettings( + controller, + settings.copyWith( + vault: settings.vault.copyWith(tokenRef: value), + ), + ), + ), + _buildSecureField( + controller: _vaultTokenController, + label: + '${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})', + hasStoredValue: hasStoredVaultToken, + fieldState: _vaultTokenState, + onStateChanged: (value) => setState(() => _vaultTokenState = value), + loadValue: controller.settingsController.loadVaultToken, + onSubmitted: (value) async => controller.saveVaultTokenDraft(value), + storedHelperText: appText( + '已安全保存,默认以 **** 显示,点击查看后读取真实值。', + 'Stored securely. Shows as **** until you reveal it.', + ), + emptyHelperText: appText( + '输入后先进入草稿;保存后才会写入安全存储。', + 'Values stage into draft first and only persist to secure storage after Save.', + ), + ), + const SizedBox(height: 12), + _buildSettingsSectionActions( + controller: controller, + testKey: const ValueKey('vault-test-button'), + saveKey: const ValueKey('vault-save-button'), + applyKey: const ValueKey('vault-apply-button'), + onTest: () => _testVaultConnection(controller, settings), + onSave: () => _handleTopLevelSave(controller), + onApply: () => _handleTopLevelApply(controller), + testLabel: + '${appText('测试连接', 'Test Connection')} · ${controller.settingsController.vaultStatus}', + ), + ], + ), + ); + } + + Widget _buildAiGatewayCard( + BuildContext context, + AppController controller, + SettingsSnapshot settings, ) { _syncDraftControllerValue( _aiGatewayNameController, @@ -884,205 +1306,57 @@ class _SettingsPageState extends State { ); final hasStoredAiGatewayApiKey = controller.settingsController.secureRefs['ai_gateway_api_key'] != null; - final hasStoredVaultToken = - controller.settingsController.secureRefs['vault_token'] != null; final statusTheme = _aiGatewayFeedbackTheme( context, _aiGatewayTestMessage.isEmpty ? settings.aiGateway.syncState : _aiGatewayTestState, ); - return [ - SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'OpenClaw Gateway', - style: Theme.of(context).textTheme.titleLarge, + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('AI Gateway', 'AI Gateway'), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + TextField( + key: const ValueKey('ai-gateway-name-field'), + controller: _aiGatewayNameController, + decoration: InputDecoration( + labelText: appText('配置名称', 'Profile Name'), ), - const SizedBox(height: 16), - Text( - '${controller.connection.status.label} · ${controller.connection.remoteAddress ?? '${settings.gateway.host}:${settings.gateway.port}'}', - style: Theme.of(context).textTheme.bodyLarge, + onChanged: (_) => unawaited( + _saveAiGatewayDraft(controller, settings).catchError((_) {}), ), - const SizedBox(height: 16), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - FilledButton.tonal( - onPressed: () => showDialog( - context: context, - builder: (context) => GatewayConnectDialog( - controller: controller, - onDone: () => Navigator.of(context).pop(), - ), - ), - child: Text(appText('打开连接面板', 'Open Connect Panel')), - ), - OutlinedButton( - onPressed: controller.refreshGatewayHealth, - child: Text(appText('刷新健康状态', 'Refresh Health')), - ), - ], + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), + ), + const SizedBox(height: 14), + TextField( + key: const ValueKey('ai-gateway-url-field'), + controller: _aiGatewayUrlController, + decoration: InputDecoration( + labelText: appText('Gateway URL', 'Gateway URL'), ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: controller.selectedAgentId.isEmpty - ? '' - : controller.selectedAgentId, - decoration: InputDecoration( - labelText: appText('当前代理', 'Selected Agent'), - ), - items: [ - DropdownMenuItem( - value: '', - child: Text(appText('主代理', 'Main')), - ), - ...controller.agents.map( - (agent) => DropdownMenuItem( - value: agent.id, - child: Text(agent.name), - ), - ), - ], - onChanged: controller.selectAgent, + onChanged: (_) => unawaited( + _saveAiGatewayDraft(controller, settings).catchError((_) {}), ), - ], - ), - ), - const SizedBox(height: 16), - _buildDeviceSecurityCard(context, controller), - const SizedBox(height: 16), - SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('Vault 服务', 'Vault Server'), - style: Theme.of(context).textTheme.titleLarge, + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), + ), + const SizedBox(height: 14), + TextField( + key: const ValueKey('ai-gateway-api-key-ref-field'), + controller: _aiGatewayApiKeyRefController, + decoration: InputDecoration( + labelText: appText('API Key 引用', 'API Key Ref'), ), - const SizedBox(height: 16), - _EditableField( - label: appText('地址', 'Address'), - value: settings.vault.address, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - vault: settings.vault.copyWith(address: value), - ), - ), + onChanged: (_) => unawaited( + _saveAiGatewayDraft(controller, settings).catchError((_) {}), ), - _EditableField( - label: appText('命名空间', 'Namespace'), - value: settings.vault.namespace, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - vault: settings.vault.copyWith(namespace: value), - ), - ), - ), - _EditableField( - label: appText('认证模式', 'Auth Mode'), - value: settings.vault.authMode, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - vault: settings.vault.copyWith(authMode: value), - ), - ), - ), - _EditableField( - label: appText('Token 引用', 'Token Ref'), - value: settings.vault.tokenRef, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - vault: settings.vault.copyWith(tokenRef: value), - ), - ), - ), - _buildSecureField( - controller: _vaultTokenController, - label: - '${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})', - hasStoredValue: hasStoredVaultToken, - fieldState: _vaultTokenState, - onStateChanged: (value) => - setState(() => _vaultTokenState = value), - loadValue: controller.settingsController.loadVaultToken, - onSubmitted: (value) async => - controller.saveVaultTokenDraft(value), - storedHelperText: appText( - '已安全保存,默认以 **** 显示,点击查看后读取真实值。', - 'Stored securely. Shows as **** until you reveal it.', - ), - emptyHelperText: appText( - '输入后先进入草稿;顶部保存后才会写入安全存储。', - 'Values stage into draft first and only persist to secure storage after Save.', - ), - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton( - onPressed: () => controller.testVaultConnection(), - child: Text( - '${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}', - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('AI Gateway', 'AI Gateway'), - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 16), - TextField( - key: const ValueKey('ai-gateway-name-field'), - controller: _aiGatewayNameController, - decoration: InputDecoration( - labelText: appText('配置名称', 'Profile Name'), - ), - onChanged: (_) => unawaited( - _saveAiGatewayDraft(controller, settings).catchError((_) {}), - ), - onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), - ), - const SizedBox(height: 14), - TextField( - key: const ValueKey('ai-gateway-url-field'), - controller: _aiGatewayUrlController, - decoration: InputDecoration( - labelText: appText('Gateway URL', 'Gateway URL'), - ), - onChanged: (_) => unawaited( - _saveAiGatewayDraft(controller, settings).catchError((_) {}), - ), - onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), - ), - const SizedBox(height: 14), - TextField( - key: const ValueKey('ai-gateway-api-key-ref-field'), - controller: _aiGatewayApiKeyRefController, - decoration: InputDecoration( - labelText: appText('API Key 引用', 'API Key Ref'), - ), - onChanged: (_) => unawaited( - _saveAiGatewayDraft(controller, settings).catchError((_) {}), - ), - onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), - ), - _buildSecureField( + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), + ), + _buildSecureField( fieldKey: const ValueKey('ai-gateway-api-key-field'), controller: _aiGatewayApiKeyController, label: @@ -1095,175 +1369,159 @@ class _SettingsPageState extends State { onSubmitted: (value) async => controller.saveAiGatewayApiKeyDraft(value), storedHelperText: appText( - '已安全保存,默认以 **** 显示;可直接测试,也可保存草稿后再统一提交。', - 'Stored securely. Test directly or save to draft before the global submit.', + '已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。', + 'Stored securely. Test directly or submit it with the local Save / Apply actions.', ), emptyHelperText: appText( - '输入后可测试连接,或先保存到草稿,顶部再统一保存/应用。', - 'Test the connection now, or stage it for the top-level Save / Apply flow.', + '输入后可直接测试,也可通过本区或顶部按钮统一保存/应用。', + 'Test it now, or use the local or top-level Save / Apply actions.', ), + ), + const SizedBox(height: 12), + _buildSettingsSectionActions( + controller: controller, + testKey: const ValueKey('ai-gateway-test-button'), + saveKey: const ValueKey('ai-gateway-save-button'), + applyKey: const ValueKey('ai-gateway-apply-button'), + testing: _aiGatewayTesting, + onTest: () => _testAiGatewayConnection(controller, settings), + onSave: () => _saveAiGatewayAndPersist(controller, settings), + onApply: () => _saveAiGatewayAndApply(controller, settings), + ), + const SizedBox(height: 12), + Text( + settings.aiGateway.syncMessage, + style: Theme.of(context).textTheme.bodySmall, + ), + if (_aiGatewayTestMessage.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + key: const ValueKey('ai-gateway-test-feedback'), + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: statusTheme.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: statusTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _aiGatewayTestMessage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: statusTheme.foreground, + fontWeight: FontWeight.w600, + ), + ), + if (_aiGatewayTestEndpoint.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + _aiGatewayTestEndpoint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusTheme.foreground, + ), + ), + ], + ], + ), + ), + ], + if (settings.aiGateway.availableModels.isNotEmpty) ...[ + const SizedBox(height: 16), + TextField( + key: const ValueKey('ai-gateway-model-search'), + controller: _aiGatewayModelSearchController, + decoration: InputDecoration( + labelText: appText('搜索模型', 'Search models'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _aiGatewayModelSearchController.text.trim().isEmpty + ? null + : IconButton( + tooltip: appText('清空搜索', 'Clear search'), + onPressed: () { + _aiGatewayModelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.close_rounded), + ), + ), + onChanged: (_) => setState(() {}), ), const SizedBox(height: 12), Wrap( spacing: 10, runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - OutlinedButton( - key: const ValueKey('ai-gateway-test-button'), - onPressed: _aiGatewayTesting - ? null - : () => _testAiGatewayConnection(controller, settings), - child: Text( - _aiGatewayTesting - ? appText('测试中...', 'Testing...') - : appText('测试连接', 'Test Connection'), + Text( + appText( + '已选 ${selectedModels.length} / ${settings.aiGateway.availableModels.length}', + 'Selected ${selectedModels.length} / ${settings.aiGateway.availableModels.length}', ), + style: Theme.of(context).textTheme.bodySmall, ), - FilledButton.tonal( - key: const ValueKey('ai-gateway-save-draft-button'), - onPressed: _aiGatewayTesting + OutlinedButton( + key: const ValueKey('ai-gateway-select-filtered'), + onPressed: filteredModels.isEmpty ? null - : () => _saveAiGatewayDraft(controller, settings), - child: Text(appText('保存草稿', 'Save Draft')), + : () async { + await controller.updateAiGatewaySelection( + { + ...selectedModels, + ...filteredModels, + }.toList(growable: false), + ); + }, + child: Text(appText('选择筛选结果', 'Select filtered')), + ), + OutlinedButton( + key: const ValueKey('ai-gateway-reset-default'), + onPressed: () async { + await controller.updateAiGatewaySelection( + settings.aiGateway.availableModels + .take(5) + .toList(growable: false), + ); + }, + child: Text(appText('恢复默认 5 个', 'Reset default 5')), ), ], ), const SizedBox(height: 12), - Text( - settings.aiGateway.syncMessage, - style: Theme.of(context).textTheme.bodySmall, - ), - if (_aiGatewayTestMessage.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - key: const ValueKey('ai-gateway-test-feedback'), - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: statusTheme.background, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: statusTheme.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _aiGatewayTestMessage, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: statusTheme.foreground, - fontWeight: FontWeight.w600, - ), - ), - if (_aiGatewayTestEndpoint.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - _aiGatewayTestEndpoint, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: statusTheme.foreground, - ), - ), - ], - ], - ), - ), - ], - if (settings.aiGateway.availableModels.isNotEmpty) ...[ - const SizedBox(height: 16), - TextField( - key: const ValueKey('ai-gateway-model-search'), - controller: _aiGatewayModelSearchController, - decoration: InputDecoration( - labelText: appText('搜索模型', 'Search models'), - prefixIcon: const Icon(Icons.search_rounded), - suffixIcon: - _aiGatewayModelSearchController.text.trim().isEmpty - ? null - : IconButton( - tooltip: appText('清空搜索', 'Clear search'), - onPressed: () { - _aiGatewayModelSearchController.clear(); - setState(() {}); - }, - icon: const Icon(Icons.close_rounded), - ), - ), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: 12), + if (filteredModels.isEmpty) + Text( + appText('没有匹配的模型。', 'No matching models.'), + style: Theme.of(context).textTheme.bodySmall, + ) + else Wrap( - spacing: 10, - runSpacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - appText( - '已选 ${selectedModels.length} / ${settings.aiGateway.availableModels.length}', - 'Selected ${selectedModels.length} / ${settings.aiGateway.availableModels.length}', - ), - style: Theme.of(context).textTheme.bodySmall, - ), - OutlinedButton( - key: const ValueKey('ai-gateway-select-filtered'), - onPressed: filteredModels.isEmpty - ? null - : () async { - await controller.updateAiGatewaySelection( - { - ...selectedModels, - ...filteredModels, - }.toList(growable: false), - ); - }, - child: Text(appText('选择筛选结果', 'Select filtered')), - ), - OutlinedButton( - key: const ValueKey('ai-gateway-reset-default'), - onPressed: () async { - await controller.updateAiGatewaySelection( - settings.aiGateway.availableModels - .take(5) - .toList(growable: false), + spacing: 8, + runSpacing: 8, + children: filteredModels + .map((modelId) { + final selected = selectedModels.contains(modelId); + return FilterChip( + label: Text(modelId), + selected: selected, + onSelected: (_) async { + final nextSelection = selected + ? selectedModels + .where((item) => item != modelId) + .toList(growable: true) + : [...selectedModels, modelId]; + await controller.updateAiGatewaySelection( + nextSelection, + ); + }, ); - }, - child: Text(appText('恢复默认 5 个', 'Reset default 5')), - ), - ], + }) + .toList(growable: false), ), - const SizedBox(height: 12), - if (filteredModels.isEmpty) - Text( - appText('没有匹配的模型。', 'No matching models.'), - style: Theme.of(context).textTheme.bodySmall, - ) - else - Wrap( - spacing: 8, - runSpacing: 8, - children: filteredModels - .map((modelId) { - final selected = selectedModels.contains(modelId); - return FilterChip( - label: Text(modelId), - selected: selected, - onSelected: (_) async { - final nextSelection = selected - ? selectedModels - .where((item) => item != modelId) - .toList(growable: true) - : [...selectedModels, modelId]; - await controller.updateAiGatewaySelection( - nextSelection, - ); - }, - ); - }) - .toList(growable: false), - ), - ], ], - ), + ], ), - ]; + ); } List _buildAppearance( @@ -2189,6 +2447,14 @@ class _SettingsPageState extends State { } Future _captureVisibleSecretDrafts(AppController controller) async { + final gatewayToken = _gatewayTokenController.text.trim(); + if (gatewayToken.isNotEmpty) { + controller.saveGatewayTokenDraft(gatewayToken); + } + final gatewayPassword = _gatewayPasswordController.text.trim(); + if (gatewayPassword.isNotEmpty) { + controller.saveGatewayPasswordDraft(gatewayPassword); + } final aiGatewayApiKey = _secretOverride( _aiGatewayApiKeyController, _aiGatewayApiKeyState, @@ -2220,6 +2486,8 @@ class _SettingsPageState extends State { _aiGatewayApiKeyState = const _SecretFieldUiState(); _vaultTokenState = const _SecretFieldUiState(); _ollamaApiKeyState = const _SecretFieldUiState(); + _gatewayTokenController.clear(); + _gatewayPasswordController.clear(); _primeSecureFieldController( _aiGatewayApiKeyController, hasStoredValue: hasStoredAiGatewayApiKey, @@ -2237,6 +2505,154 @@ class _SettingsPageState extends State { ); } + String _connectionModeLabel(RuntimeConnectionMode mode) { + return switch (mode) { + RuntimeConnectionMode.unconfigured => appText( + '仅 AI Gateway', + 'AI Gateway Only', + ), + RuntimeConnectionMode.local => appText( + '本地 OpenClaw Gateway', + 'Local OpenClaw Gateway', + ), + RuntimeConnectionMode.remote => appText( + '远程 OpenClaw Gateway', + 'Remote OpenClaw Gateway', + ), + }; + } + + AssistantExecutionTarget _assistantExecutionTargetForMode( + RuntimeConnectionMode mode, + ) { + return switch (mode) { + RuntimeConnectionMode.unconfigured => + AssistantExecutionTarget.aiGatewayOnly, + RuntimeConnectionMode.local => AssistantExecutionTarget.local, + RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, + }; + } + + void _syncGatewayDraftControllers(SettingsSnapshot settings) { + final mode = _gatewayDraftMode ?? settings.gateway.mode; + final useSetupCode = + _gatewayDraftUseSetupCode ?? settings.gateway.useSetupCode; + final tls = mode == RuntimeConnectionMode.local + ? false + : (_gatewayDraftTls ?? settings.gateway.tls); + _gatewayDraftMode = mode; + _gatewayDraftUseSetupCode = useSetupCode; + _gatewayDraftTls = tls; + _syncDraftControllerValue( + _gatewaySetupCodeController, + settings.gateway.setupCode, + syncedValue: _gatewaySetupCodeSyncedValue, + onSyncedValueChanged: (value) => _gatewaySetupCodeSyncedValue = value, + ); + _syncDraftControllerValue( + _gatewayHostController, + settings.gateway.host, + syncedValue: _gatewayHostSyncedValue, + onSyncedValueChanged: (value) => _gatewayHostSyncedValue = value, + ); + _syncDraftControllerValue( + _gatewayPortController, + '${settings.gateway.port}', + syncedValue: _gatewayPortSyncedValue, + onSyncedValueChanged: (value) => _gatewayPortSyncedValue = value, + ); + } + + GatewayConnectionProfile _buildGatewayDraftProfile(SettingsSnapshot settings) { + final current = settings.gateway; + final mode = _gatewayDraftMode ?? current.mode; + final useSetupCode = mode == RuntimeConnectionMode.unconfigured + ? false + : (_gatewayDraftUseSetupCode ?? current.useSetupCode); + final tls = mode == RuntimeConnectionMode.local + ? false + : (_gatewayDraftTls ?? current.tls); + final parsedPort = int.tryParse(_gatewayPortController.text.trim()); + final decoded = useSetupCode + ? decodeGatewaySetupCode(_gatewaySetupCodeController.text) + : null; + final fallbackPort = mode == RuntimeConnectionMode.local + ? 18789 + : tls + ? 443 + : current.port; + return current.copyWith( + mode: mode, + useSetupCode: useSetupCode, + setupCode: useSetupCode ? _gatewaySetupCodeController.text.trim() : '', + host: useSetupCode + ? (decoded?.host ?? current.host) + : _gatewayHostController.text.trim(), + port: useSetupCode + ? (decoded?.port ?? current.port) + : (parsedPort ?? fallbackPort), + tls: useSetupCode ? (decoded?.tls ?? tls) : tls, + ); + } + + Future _saveGatewayDraft( + AppController controller, + SettingsSnapshot settings, + ) async { + final profile = _buildGatewayDraftProfile(settings); + final nextSettings = settings.copyWith( + gateway: profile, + assistantExecutionTarget: _assistantExecutionTargetForMode(profile.mode), + ); + await _saveSettings(controller, nextSettings); + if (!mounted) { + return; + } + setState(() { + _gatewaySetupCodeSyncedValue = profile.setupCode; + _gatewayHostSyncedValue = profile.host; + _gatewayPortSyncedValue = '${profile.port}'; + _gatewayDraftMode = profile.mode; + _gatewayDraftUseSetupCode = profile.useSetupCode; + _gatewayDraftTls = profile.tls; + _gatewayTestState = 'idle'; + _gatewayTestMessage = ''; + _gatewayTestEndpoint = ''; + }); + } + + Future _saveGatewayAndPersist( + AppController controller, + SettingsSnapshot settings, + ) async { + await _saveGatewayDraft(controller, settings); + await _handleTopLevelSave(controller); + } + + Future _saveGatewayAndApply( + AppController controller, + SettingsSnapshot settings, + ) async { + await _saveGatewayDraft(controller, settings); + await _handleTopLevelApply(controller); + } + + Future _saveAiGatewayAndPersist( + AppController controller, + SettingsSnapshot settings, + ) async { + await _saveAiGatewayDraft(controller, settings); + await _handleTopLevelSave(controller); + } + + Future _saveAiGatewayAndApply( + AppController controller, + SettingsSnapshot settings, + ) async { + await _saveAiGatewayDraft(controller, settings); + await _handleTopLevelApply(controller); + } + Future _saveMultiAgentConfig( AppController controller, MultiAgentConfig config, @@ -2320,6 +2736,93 @@ class _SettingsPageState extends State { } } + Future _testVaultConnection( + AppController controller, + SettingsSnapshot settings, + ) async { + final messenger = ScaffoldMessenger.of(context); + final token = _secretOverride(_vaultTokenController, _vaultTokenState); + final message = await controller.testVaultConnectionDraft( + snapshot: settings, + tokenOverride: token, + ); + if (!mounted) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(message))); + } + + Future _testGatewayConnection( + AppController controller, + SettingsSnapshot settings, { + required AssistantExecutionTarget executionTarget, + }) async { + final messenger = ScaffoldMessenger.of(context); + final gatewayDraft = _buildGatewayDraftProfile(settings); + final token = _gatewayTokenController.text.trim(); + final password = _gatewayPasswordController.text.trim(); + setState(() => _gatewayTesting = true); + try { + final result = await controller.testGatewayConnectionDraft( + profile: gatewayDraft, + executionTarget: executionTarget, + tokenOverride: token, + passwordOverride: password, + ); + if (!mounted) { + return; + } + setState(() { + _gatewayTestState = result.state; + _gatewayTestMessage = result.message; + _gatewayTestEndpoint = result.endpoint; + }); + messenger.showSnackBar(SnackBar(content: Text(result.message))); + } finally { + if (mounted) { + setState(() => _gatewayTesting = false); + } + } + } + + Widget _buildSettingsSectionActions({ + required AppController controller, + required Key testKey, + required Key saveKey, + required Key applyKey, + required Future Function() onTest, + required Future Function() onSave, + required Future Function() onApply, + bool testing = false, + String? testLabel, + }) { + return Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton( + key: testKey, + onPressed: testing ? null : () => onTest(), + child: Text( + testing + ? appText('测试中...', 'Testing...') + : (testLabel ?? appText('测试连接', 'Test Connection')), + ), + ), + OutlinedButton( + key: saveKey, + onPressed: () => onSave(), + child: Text(appText('保存', 'Save')), + ), + FilledButton.tonal( + key: applyKey, + onPressed: () => onApply(), + child: Text(appText('应用', 'Apply')), + ), + ], + ); + } + List _filterAiGatewayModels(List models) { final query = _aiGatewayModelSearchController.text.trim().toLowerCase(); if (query.isEmpty) { @@ -3327,6 +3830,49 @@ class _InlineSwitchField extends StatelessWidget { } } +class _GatewaySecretStatusCard extends StatelessWidget { + const _GatewaySecretStatusCard({ + required this.message, + required this.locked, + this.onClear, + }); + + final String message; + final bool locked; + final Future Function()? onClear; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(locked ? Icons.lock_rounded : Icons.info_outline_rounded, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall, + ), + ), + if (onClear != null) + TextButton( + onPressed: () => onClear!.call(), + child: Text(appText('清除', 'Clear')), + ), + ], + ), + ); + } +} + class _AiGatewayFeedbackTheme { const _AiGatewayFeedbackTheme({ required this.background, diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 7852aa4a..89a71b34 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -215,15 +215,31 @@ class SettingsController extends ChangeNotifier { } Future testOllamaConnection({required bool cloud}) async { + return testOllamaConnectionDraft( + cloud: cloud, + localConfig: _snapshot.ollamaLocal, + cloudConfig: _snapshot.ollamaCloud, + ); + } + + Future testOllamaConnectionDraft({ + required bool cloud, + required OllamaLocalConfig localConfig, + required OllamaCloudConfig cloudConfig, + String apiKeyOverride = '', + }) async { final base = cloud - ? _snapshot.ollamaCloud.baseUrl.trim() - : _snapshot.ollamaLocal.endpoint.trim(); + ? cloudConfig.baseUrl.trim() + : localConfig.endpoint.trim(); if (base.isEmpty) { final message = 'Missing endpoint'; _ollamaStatus = message; notifyListeners(); return message; } + final cloudApiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await _store.loadOllamaCloudApiKey())?.trim() ?? ''; try { final uri = Uri.parse( cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags', @@ -232,7 +248,7 @@ class SettingsController extends ChangeNotifier { uri, headers: cloud ? { - if (_secureRefs[_snapshot.ollamaCloud.apiKeyRef] != null) + if (cloudApiKey.isNotEmpty) 'Authorization': 'Bearer live-secret', } : const {}, @@ -252,7 +268,14 @@ class SettingsController extends ChangeNotifier { } Future testVaultConnection() async { - final address = _snapshot.vault.address.trim(); + return testVaultConnectionDraft(_snapshot.vault); + } + + Future testVaultConnectionDraft( + VaultConfig profile, { + String tokenOverride = '', + }) async { + final address = profile.address.trim(); if (address.isEmpty) { const message = 'Missing address'; _vaultStatus = message; @@ -264,11 +287,13 @@ class SettingsController extends ChangeNotifier { '$address${address.endsWith('/') ? '' : '/'}v1/sys/health', ); final headers = { - if (_snapshot.vault.namespace.trim().isNotEmpty) - 'X-Vault-Namespace': _snapshot.vault.namespace.trim(), + if (profile.namespace.trim().isNotEmpty) + 'X-Vault-Namespace': profile.namespace.trim(), }; - final token = await _store.loadVaultToken(); - if (token != null && token.trim().isNotEmpty) { + final token = tokenOverride.trim().isNotEmpty + ? tokenOverride.trim() + : (await _store.loadVaultToken())?.trim() ?? ''; + if (token.trim().isNotEmpty) { headers['X-Vault-Token'] = token.trim(); } final response = await _simpleGet(uri, headers: headers); diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart deleted file mode 100644 index c0f3a417..00000000 --- a/lib/widgets/gateway_connect_dialog.dart +++ /dev/null @@ -1,760 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../app/app_controller.dart'; -import '../app/ui_feature_manifest.dart'; -import '../i18n/app_language.dart'; -import '../runtime/runtime_bootstrap.dart'; -import '../runtime/runtime_models.dart'; -import 'section_tabs.dart'; -import '../theme/app_palette.dart'; -import '../theme/app_theme.dart'; - -class GatewayConnectDialog extends StatefulWidget { - const GatewayConnectDialog({ - super.key, - required this.controller, - this.compact = false, - this.onDone, - }); - - final AppController controller; - final bool compact; - final VoidCallback? onDone; - - @override - State createState() => _GatewayConnectDialogState(); -} - -class _GatewayConnectDialogState extends State { - late final TextEditingController _setupCodeController; - late final TextEditingController _hostController; - late final TextEditingController _portController; - final TextEditingController _tokenController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - - String _mode = 'setup'; - String _bootstrapToken = ''; - bool _tls = true; - bool _obscureSharedToken = true; - RuntimeConnectionMode _connectionMode = RuntimeConnectionMode.remote; - bool _submitting = false; - - bool get _isAiGatewayOnlyMode => - _mode == 'manual' && - _connectionMode == RuntimeConnectionMode.unconfigured; - - bool get _manualGatewayFieldsEnabled => !_isAiGatewayOnlyMode; - - bool get _credentialFieldsEnabled => - _mode == 'setup' || _manualGatewayFieldsEnabled; - - String _connectionModeLabel(RuntimeConnectionMode mode) { - return switch (mode) { - RuntimeConnectionMode.unconfigured => appText( - '仅 AI Gateway', - 'AI Gateway Only', - ), - RuntimeConnectionMode.local => appText( - '本地 OpenClaw Gateway', - 'Local OpenClaw Gateway', - ), - RuntimeConnectionMode.remote => appText( - '远程 OpenClaw Gateway', - 'Remote OpenClaw Gateway', - ), - }; - } - - @override - void initState() { - super.initState(); - final profile = widget.controller.settings.gateway; - final executionTarget = widget.controller.currentAssistantExecutionTarget; - _setupCodeController = TextEditingController(text: profile.setupCode); - _hostController = TextEditingController(text: profile.host); - _portController = TextEditingController(text: '${profile.port}'); - _tls = profile.tls; - _connectionMode = switch (executionTarget) { - AssistantExecutionTarget.aiGatewayOnly => - RuntimeConnectionMode.unconfigured, - AssistantExecutionTarget.local => RuntimeConnectionMode.local, - AssistantExecutionTarget.remote => RuntimeConnectionMode.remote, - }; - _mode = executionTarget == AssistantExecutionTarget.aiGatewayOnly - ? 'manual' - : (profile.useSetupCode ? 'setup' : 'manual'); - _loadBootstrapPrefill(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final uiFeatures = widget.controller.featuresFor( - resolveUiFeaturePlatformFromContext(context), - ); - _connectionMode = _sanitizeConnectionMode(_connectionMode, uiFeatures); - } - - @override - void dispose() { - _setupCodeController.dispose(); - _hostController.dispose(); - _portController.dispose(); - _tokenController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final uiFeatures = widget.controller.featuresFor( - resolveUiFeaturePlatformFromContext(context), - ); - final availableConnectionModes = _availableConnectionModes(uiFeatures); - final theme = Theme.of(context); - final palette = context.palette; - final horizontalPadding = widget.compact ? 20.0 : 24.0; - final verticalPadding = widget.compact ? 18.0 : 22.0; - final dialogTitleStyle = theme.textTheme.headlineSmall?.copyWith( - fontSize: AppTypography.titleSize, - height: AppTypography.titleHeight, - letterSpacing: -0.18, - fontWeight: AppTypography.titleWeight, - ); - final supportingCopyStyle = theme.textTheme.bodyMedium?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ); - final fieldLabelStyle = theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textMuted, - ); - final floatingFieldLabelStyle = fieldLabelStyle?.copyWith( - color: palette.textSecondary, - fontWeight: FontWeight.w500, - ); - final storedGatewayTokenMask = widget.controller.storedGatewayTokenMask; - final hasStoredGatewayToken = - storedGatewayTokenMask != null && storedGatewayTokenMask.isNotEmpty; - final typedGatewayToken = _tokenController.text.trim(); - final willUseStoredGatewayToken = - typedGatewayToken.isEmpty && hasStoredGatewayToken; - final showSharedTokenStatusCard = - _credentialFieldsEnabled && - (willUseStoredGatewayToken || typedGatewayToken.isNotEmpty); - final body = Theme( - data: theme.copyWith( - inputDecorationTheme: theme.inputDecorationTheme.copyWith( - labelStyle: fieldLabelStyle, - floatingLabelStyle: floatingFieldLabelStyle, - hintStyle: fieldLabelStyle, - ), - ), - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - horizontalPadding, - verticalPadding, - horizontalPadding, - verticalPadding, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - appText('Gateway 访问', 'Gateway Access'), - style: dialogTitleStyle, - ), - const SizedBox(height: AppSpacing.section), - Text( - appText( - '通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。远程模式保持显式 TLS 直连;也可切换到仅 AI Gateway 模式,仅使用模型路由而不建立 Gateway 会话。', - 'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS. Remote mode keeps TLS explicit for direct access. You can also switch to AI Gateway Only mode to use model routing without opening a gateway session.', - ), - style: supportingCopyStyle, - ), - const SizedBox(height: AppSpacing.section), - SectionTabs( - items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')], - value: _mode == 'setup' - ? appText('配置码', 'Setup Code') - : appText('手动配置', 'Manual'), - size: SectionTabsSize.small, - onChanged: (value) => setState( - () => _mode = value == appText('配置码', 'Setup Code') - ? 'setup' - : 'manual', - ), - ), - const SizedBox(height: AppSpacing.section), - _StatusBanner(controller: widget.controller), - const SizedBox(height: 14), - if (_mode == 'setup') ...[ - TextField( - controller: _setupCodeController, - minLines: 4, - maxLines: 6, - decoration: InputDecoration( - labelText: appText('配置码', 'Setup Code'), - hintText: appText( - '粘贴 Gateway 配置码或 JSON 负载', - 'Paste gateway setup code or JSON payload', - ), - ), - ), - ] else ...[ - _FormSectionLabel(label: appText('连接目标', 'Connection Target')), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: _connectionMode, - decoration: InputDecoration( - labelText: appText('工作模式', 'Work Mode'), - ), - items: availableConnectionModes - .map( - (mode) => DropdownMenuItem( - value: mode, - child: Text(_connectionModeLabel(mode)), - ), - ) - .toList(), - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - _connectionMode = value; - if (value == RuntimeConnectionMode.local) { - _hostController.text = '127.0.0.1'; - _portController.text = '18789'; - _tls = false; - } - }); - }, - ), - if (_isAiGatewayOnlyMode) ...[ - const SizedBox(height: 10), - Text( - appText( - '当前模式仅通过 AI Gateway 处理任务,不会建立 OpenClaw Gateway 会话。', - 'This mode routes tasks through AI Gateway only and does not establish an OpenClaw Gateway session.', - ), - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - ], - const SizedBox(height: 12), - TextField( - controller: _hostController, - enabled: _manualGatewayFieldsEnabled, - decoration: InputDecoration(labelText: appText('主机', 'Host')), - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: TextField( - controller: _portController, - enabled: _manualGatewayFieldsEnabled, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: appText('端口', 'Port'), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: _TlsToggleCard( - value: _tls, - label: appText('TLS', 'TLS'), - enabled: - _manualGatewayFieldsEnabled && - _connectionMode != RuntimeConnectionMode.local, - onChanged: - !_manualGatewayFieldsEnabled || - _connectionMode == RuntimeConnectionMode.local - ? null - : (value) => setState(() => _tls = value), - ), - ), - ], - ), - ], - const SizedBox(height: 14), - _FormSectionLabel(label: appText('凭证', 'Credentials')), - const SizedBox(height: 8), - TextField( - controller: _tokenController, - enabled: _credentialFieldsEnabled, - obscureText: _obscureSharedToken, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - labelText: appText('共享 Token', 'Shared Token'), - hintText: appText( - '可选:覆盖默认 Gateway Token', - 'Optional override for gateway token', - ), - suffixIcon: IconButton( - tooltip: _obscureSharedToken - ? appText('显示 Token', 'Show token') - : appText('隐藏 Token', 'Hide token'), - onPressed: !_credentialFieldsEnabled - ? null - : () => setState( - () => _obscureSharedToken = !_obscureSharedToken, - ), - icon: Icon( - _obscureSharedToken - ? Icons.visibility_off_rounded - : Icons.visibility_rounded, - ), - ), - ), - onChanged: (_) => setState(() {}), - ), - if (showSharedTokenStatusCard) ...[ - const SizedBox(height: 10), - _SharedTokenStatusCard( - hasStoredGatewayToken: hasStoredGatewayToken, - storedGatewayTokenMask: storedGatewayTokenMask, - willUseStoredGatewayToken: willUseStoredGatewayToken, - overridingStoredToken: - hasStoredGatewayToken && typedGatewayToken.isNotEmpty, - onClearStoredToken: hasStoredGatewayToken - ? () async { - await widget.controller.clearStoredGatewayToken(); - if (mounted) { - setState(() {}); - } - } - : null, - ), - ], - const SizedBox(height: 12), - TextField( - controller: _passwordController, - enabled: _credentialFieldsEnabled, - obscureText: true, - decoration: InputDecoration( - labelText: appText('密码', 'Password'), - hintText: appText('可选:共享密码', 'Optional shared password'), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - if (widget.controller.connection.status == - RuntimeConnectionStatus.connected) ...[ - OutlinedButton.icon( - onPressed: _submitting - ? null - : () async { - setState(() => _submitting = true); - await widget.controller.disconnectGateway(); - if (mounted) { - setState(() => _submitting = false); - } - }, - icon: const Icon(Icons.link_off_rounded), - label: Text(appText('断开连接', 'Disconnect')), - ), - const SizedBox(width: 10), - ], - Expanded( - child: FilledButton.icon( - onPressed: _submitting ? null : _submit, - icon: const Icon(Icons.wifi_tethering_rounded), - label: Text( - _submitting - ? (_isAiGatewayOnlyMode - ? appText('应用中…', 'Applying…') - : appText('连接中…', 'Connecting…')) - : (_isAiGatewayOnlyMode - ? appText('应用模式', 'Apply Mode') - : appText('连接', 'Connect')), - ), - ), - ), - ], - ), - ], - ), - ), - ); - - if (widget.compact) { - return body; - } - - return Dialog( - insetPadding: const EdgeInsets.all(AppSpacing.page), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: body, - ), - ); - } - - Future _loadBootstrapPrefill() async { - final bootstrap = await RuntimeBootstrapConfig.load( - workspacePathHint: widget.controller.settings.workspacePath, - cliPathHint: widget.controller.settings.cliPath, - ); - final preferred = bootstrap.preferredGatewayFor(_connectionMode); - if (!mounted || preferred == null) { - return; - } - final profile = widget.controller.settings.gateway; - final defaults = GatewayConnectionProfile.defaults(); - final shouldPrefillEndpoint = - profile.setupCode.trim().isEmpty && - profile.host.trim() == defaults.host && - profile.port == defaults.port; - setState(() { - if (shouldPrefillEndpoint) { - if (_connectionMode != RuntimeConnectionMode.unconfigured) { - _connectionMode = preferred.mode; - } - _hostController.text = preferred.host; - _portController.text = '${preferred.port}'; - _tls = preferred.tls; - } - if (_bootstrapToken.isEmpty && preferred.token.isNotEmpty) { - _bootstrapToken = preferred.token; - } - }); - } - - Future _submit() async { - setState(() => _submitting = true); - try { - final typedToken = _tokenController.text.trim(); - final resolvedToken = typedToken.isNotEmpty - ? typedToken - : widget.controller.hasStoredGatewayToken - ? '' - : _bootstrapToken; - if (_mode == 'setup') { - await widget.controller.connectWithSetupCode( - setupCode: _setupCodeController.text, - token: resolvedToken, - password: _passwordController.text, - ); - } else if (_connectionMode == RuntimeConnectionMode.unconfigured) { - await widget.controller.setAssistantExecutionTarget( - AssistantExecutionTarget.aiGatewayOnly, - ); - } else { - await widget.controller.connectManual( - host: _hostController.text, - port: int.tryParse(_portController.text.trim()) ?? 0, - tls: _tls, - mode: _connectionMode, - token: resolvedToken, - password: _passwordController.text, - ); - } - widget.onDone?.call(); - } finally { - if (mounted) { - setState(() => _submitting = false); - } - } - } - - List _availableConnectionModes( - UiFeatureAccess uiFeatures, - ) { - return [ - if (uiFeatures.supportsDirectAi) RuntimeConnectionMode.unconfigured, - if (uiFeatures.supportsLocalGateway) RuntimeConnectionMode.local, - if (uiFeatures.supportsRelayGateway) RuntimeConnectionMode.remote, - ]; - } - - RuntimeConnectionMode _sanitizeConnectionMode( - RuntimeConnectionMode mode, - UiFeatureAccess uiFeatures, - ) { - final available = _availableConnectionModes(uiFeatures); - if (available.contains(mode)) { - return mode; - } - if (available.isNotEmpty) { - return available.first; - } - return RuntimeConnectionMode.unconfigured; - } -} - -class _SharedTokenStatusCard extends StatelessWidget { - const _SharedTokenStatusCard({ - required this.hasStoredGatewayToken, - required this.storedGatewayTokenMask, - required this.willUseStoredGatewayToken, - required this.overridingStoredToken, - this.onClearStoredToken, - }); - - final bool hasStoredGatewayToken; - final String? storedGatewayTokenMask; - final bool willUseStoredGatewayToken; - final bool overridingStoredToken; - final Future Function()? onClearStoredToken; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; - final message = overridingStoredToken - ? appText( - '本次输入会覆盖已安全保存的 shared token。', - 'This entry will overwrite the stored shared token.', - ) - : willUseStoredGatewayToken - ? appText( - '已安全保存 shared token($storedGatewayTokenMask)。留空时会直接使用它连接。', - 'A shared token is already stored securely ($storedGatewayTokenMask). Leave the field empty to connect with it.', - ) - : appText( - '首次连接需要 shared token;点击连接后会写入安全存储。', - 'The first connection needs a shared token; after connect it will be saved into secure storage.', - ); - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - decoration: BoxDecoration( - color: palette.surfaceSecondary.withValues(alpha: 0.92), - border: Border.all(color: palette.strokeSoft), - borderRadius: BorderRadius.circular(AppRadius.card), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - hasStoredGatewayToken - ? Icons.lock_rounded - : Icons.inventory_2_rounded, - size: 18, - ), - const SizedBox(width: AppSpacing.compact), - Expanded( - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - ), - if (onClearStoredToken != null) - TextButton( - onPressed: () => onClearStoredToken!.call(), - child: Text(appText('清除', 'Clear')), - ), - ], - ), - ); - } -} - -class _StatusBanner extends StatelessWidget { - const _StatusBanner({required this.controller}); - - final AppController controller; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final palette = context.palette; - final connection = controller.connection; - final tone = switch (connection.status) { - RuntimeConnectionStatus.connected => palette.accentMuted, - RuntimeConnectionStatus.error => theme.colorScheme.errorContainer, - RuntimeConnectionStatus.connecting => palette.surfaceSecondary, - RuntimeConnectionStatus.offline => palette.surfaceSecondary, - }; - final statusColor = switch (connection.status) { - RuntimeConnectionStatus.connected => palette.success, - RuntimeConnectionStatus.error => palette.danger, - RuntimeConnectionStatus.connecting => palette.accent, - RuntimeConnectionStatus.offline => palette.textSecondary, - }; - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), - decoration: BoxDecoration( - color: tone, - border: Border.all(color: palette.strokeSoft), - borderRadius: BorderRadius.circular(AppRadius.card), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: statusColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - connection.status.label, - style: theme.textTheme.titleMedium?.copyWith( - fontSize: 14, - height: 16 / 14, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - connection.remoteAddress ?? 'No active gateway target', - style: theme.textTheme.bodyMedium?.copyWith( - fontSize: 13, - height: 18 / 13, - color: palette.textSecondary, - ), - ), - const SizedBox(height: 12), - _FormSectionLabel(label: appText('认证诊断', 'Auth Diagnostics')), - const SizedBox(height: 6), - Text( - connection.connectAuthSummary, - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 13, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - if (connection.pairingRequired) ...[ - const SizedBox(height: AppSpacing.section), - Text( - appText( - '当前设备需要先完成配对审批。请在已授权设备上批准该请求后重试。', - 'This device must be approved first. Approve the pairing request from an authorized device and try again.', - ), - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - if ((connection.deviceId ?? '').isNotEmpty) ...[ - const SizedBox(height: AppSpacing.compact), - Text( - appText( - '当前设备 ID: ${connection.deviceId}', - 'Current device ID: ${connection.deviceId}', - ), - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - ], - ] else if (connection.gatewayTokenMissing) ...[ - const SizedBox(height: AppSpacing.section), - Text( - appText( - '首次连接请提供共享 Token;配对完成后可继续使用本机 device token。', - 'Provide a shared token for the first connection; after pairing, this device can continue with its device token.', - ), - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - ], - if ((connection.lastError ?? '').isNotEmpty) ...[ - const SizedBox(height: AppSpacing.section), - Text( - connection.lastError!, - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 12, - height: 16 / 12, - color: palette.textSecondary, - ), - ), - ], - ], - ), - ); - } -} - -class _FormSectionLabel extends StatelessWidget { - const _FormSectionLabel({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - return Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: palette.textMuted, - letterSpacing: 0.32, - ), - ); - } -} - -class _TlsToggleCard extends StatelessWidget { - const _TlsToggleCard({ - required this.value, - required this.label, - required this.enabled, - required this.onChanged, - }); - - final bool value; - final String label; - final bool enabled; - final ValueChanged? onChanged; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - return Container( - constraints: const BoxConstraints(minHeight: AppSizes.inputHeight), - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: palette.surfacePrimary.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(AppRadius.input), - border: Border.all(color: palette.strokeSoft), - ), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: enabled ? palette.textSecondary : palette.textMuted, - ), - ), - ), - Switch.adaptive(value: value, onChanged: enabled ? onChanged : null), - ], - ), - ); - } -} diff --git a/test/features/ai_gateway_page_suite.dart b/test/features/ai_gateway_page_suite.dart index cba68388..4c3321bd 100644 --- a/test/features/ai_gateway_page_suite.dart +++ b/test/features/ai_gateway_page_suite.dart @@ -56,6 +56,16 @@ class _FakeCodexRuntime extends CodexRuntime { Future stop() async {} } +class _AiGatewayPageTestController extends AppController { + _AiGatewayPageTestController({ + required super.store, + required super.runtimeCoordinator, + }); + + @override + Future refreshMultiAgentMounts({bool sync = false}) async {} +} + void main() { testWidgets('AiGatewayPage edit settings opens detail context', ( WidgetTester tester, @@ -82,10 +92,18 @@ void main() { 'Settings external agents detail shows Codex bridge runtime states', (WidgetTester tester) async { late AppController controller; + late Directory testRoot; await tester.runAsync(() async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); - controller = AppController( + testRoot = await Directory.systemTemp.createTemp( + 'xworkmate-ai-gateway-page-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${testRoot.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => testRoot.path, + ); + controller = _AiGatewayPageTestController( store: store, runtimeCoordinator: RuntimeCoordinator( gateway: _FakeGatewayRuntime(), @@ -95,6 +113,11 @@ void main() { await _waitFor(() => !controller.initializing); }); addTearDown(() => controller.dispose()); + addTearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1600, 1000); diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 127eb9ac..7849fb07 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -11,6 +11,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart'; import 'package:xworkmate/features/assistant/assistant_page.dart'; +import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/codex_runtime.dart'; import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/gateway_runtime.dart'; @@ -311,7 +312,7 @@ void main() { ); }); - testWidgets('AssistantPage offline submit control opens gateway dialog', ( + testWidgets('AssistantPage offline submit control opens gateway settings', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -324,7 +325,8 @@ void main() { await tester.tap(find.byTooltip('连接')); await tester.pumpAndSettle(); - expect(find.text('Gateway 访问'), findsOneWidget); + expect(controller.destination, WorkspaceDestination.settings); + expect(controller.settingsDetail, SettingsDetailPage.gatewayConnection); }); testWidgets('AssistantPage keeps a minimal composer action menu', ( diff --git a/test/features/settings_ai_gateway_persistence_suite.dart b/test/features/settings_ai_gateway_persistence_suite.dart index 7d6d339f..7542b075 100644 --- a/test/features/settings_ai_gateway_persistence_suite.dart +++ b/test/features/settings_ai_gateway_persistence_suite.dart @@ -4,38 +4,44 @@ library; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/settings/settings_page.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; -import 'package:xworkmate/theme/app_theme.dart'; + +import '../test_support.dart'; void main() { testWidgets( 'SettingsPage AI Gateway draft/save/apply flow persists edited fields through the global actions', (WidgetTester tester) async { - late AppController controller; + late _AiGatewaySettingsTestController controller; await tester.runAsync(() async { SharedPreferences.setMockInitialValues({}); - controller = AppController( + final testRoot = + '${Directory.systemTemp.path}/xworkmate-widget-tests-${DateTime.now().microsecondsSinceEpoch}'; + controller = _AiGatewaySettingsTestController( store: SecureConfigStore( enableSecureStorage: false, - fallbackDirectoryPathResolver: () async => - '${Directory.systemTemp.path}/xworkmate-widget-tests', + databasePathResolver: () async => '$testRoot/settings.sqlite3', + fallbackDirectoryPathResolver: () async => testRoot, ), ); await _waitFor(() => !controller.initializing); - final staleGateway = controller.settings.aiGateway.copyWith( - name: 'default', - baseUrl: '', - apiKeyRef: 'ai_gateway_api_key', - availableModels: const ['stale-model'], - selectedModels: const ['stale-model'], - syncState: 'invalid', - syncMessage: 'Missing AI Gateway URL', - ); + }); + addTearDown(controller.dispose); + + final staleGateway = controller.settings.aiGateway.copyWith( + name: 'default', + baseUrl: '', + apiKeyRef: 'ai_gateway_api_key', + availableModels: const ['stale-model'], + selectedModels: const ['stale-model'], + syncState: 'invalid', + syncMessage: 'Missing AI Gateway URL', + ); + await tester.runAsync(() async { await controller.saveSettings( controller.settings.copyWith( aiGateway: staleGateway, @@ -46,29 +52,14 @@ void main() { refreshAfterSave: false, ); }); - addTearDown(controller.dispose); - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1600, 1000); - addTearDown(() { - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - }); - - await tester.pumpWidget( - MaterialApp( - locale: const Locale('zh'), - supportedLocales: const [Locale('zh'), Locale('en')], - localizationsDelegates: GlobalMaterialLocalizations.delegates, - theme: AppTheme.light(), - darkTheme: AppTheme.dark(), - home: Scaffold(body: SettingsPage(controller: controller)), - ), + await pumpPage( + tester, + child: SettingsPage(controller: controller), ); - await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.text('集成')); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); await tester.enterText( find.byKey(const ValueKey('ai-gateway-name-field')), @@ -82,10 +73,6 @@ void main() { find.byKey(const ValueKey('ai-gateway-api-key-ref-field')), 'ai_gateway_api_key', ); - await tester.enterText( - find.byKey(const ValueKey('ai-gateway-api-key-field')), - 'live-secret', - ); expect( tester @@ -96,21 +83,8 @@ void main() { .text, 'https://api.svc.plus/v1', ); - await tester.ensureVisible( - find.byKey(const ValueKey('ai-gateway-save-draft-button')), - ); - await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('ai-gateway-save-draft-button')), - ); - await tester.pumpAndSettle(); - - expect(controller.settings.aiGateway.baseUrl, isEmpty); - expect( - controller.settingsDraft.aiGateway.baseUrl, - 'https://api.svc.plus/v1', - ); - + expect(find.byKey(const ValueKey('ai-gateway-save-button')), findsOneWidget); + expect(find.byKey(const ValueKey('ai-gateway-apply-button')), findsOneWidget); expect( find.byKey(const ValueKey('settings-global-save-button')), findsOneWidget, @@ -119,20 +93,30 @@ void main() { find.byKey(const ValueKey('settings-global-apply-button')), findsOneWidget, ); + + expect(controller.settingsDraft.aiGateway.baseUrl, 'https://api.svc.plus/v1'); + expect(controller.settings.aiGateway.baseUrl, isEmpty); + + final saveButton = tester.widget( + find.byKey(const ValueKey('ai-gateway-save-button')), + ); await tester.runAsync(() async { - await controller.persistSettingsDraft(); - }); - await tester.runAsync(() async { + saveButton.onPressed!.call(); await _waitFor(() => controller.hasPendingSettingsApply); }); - await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 300)); expect(controller.hasPendingSettingsApply, isTrue); + expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1'); + final applyButton = tester.widget( + find.byKey(const ValueKey('ai-gateway-apply-button')), + ); await tester.runAsync(() async { - await controller.applySettingsDraft(); + applyButton.onPressed!.call(); + await _waitFor(() => !controller.hasPendingSettingsApply); }); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); expect(controller.settings.aiGateway.name, 'default'); expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1'); @@ -148,11 +132,18 @@ void main() { ); } +class _AiGatewaySettingsTestController extends AppController { + _AiGatewaySettingsTestController({super.store}); + + @override + Future refreshMultiAgentMounts({bool sync = false}) async {} +} + Future _waitFor(bool Function() predicate) async { final deadline = DateTime.now().add(const Duration(seconds: 10)); while (!predicate()) { if (DateTime.now().isAfter(deadline)) { - fail('condition not met before timeout'); + throw StateError('condition not met before timeout'); } await Future.delayed(const Duration(milliseconds: 20)); } diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 54704f80..3e7a084f 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -91,7 +91,7 @@ void main() { expect(controller.themeMode, ThemeMode.light); }); - testWidgets('SettingsPage gateway tab exposes device pairing controls', ( + testWidgets('SettingsPage integration tab exposes unified gateway controls', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -105,7 +105,12 @@ void main() { await tester.tap(find.text('集成')); await tester.pumpAndSettle(); - expect(find.text('打开连接面板'), findsOneWidget); + expect(find.text('OpenClaw Gateway'), findsOneWidget); + expect(find.text('Vault Server'), findsOneWidget); + expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsOneWidget); + expect(find.byKey(const ValueKey('gateway-test-button')), findsOneWidget); + expect(find.byKey(const ValueKey('gateway-save-button')), findsOneWidget); + expect(find.byKey(const ValueKey('gateway-apply-button')), findsOneWidget); expect( find.byKey(const ValueKey('gateway-device-security-card')), findsOneWidget, diff --git a/test/widgets/gateway_connect_dialog_suite.dart b/test/widgets/gateway_connect_dialog_suite.dart deleted file mode 100644 index fd933b6a..00000000 --- a/test/widgets/gateway_connect_dialog_suite.dart +++ /dev/null @@ -1,66 +0,0 @@ -@TestOn('vm') -library; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:xworkmate/runtime/runtime_models.dart'; -import 'package:xworkmate/widgets/gateway_connect_dialog.dart'; - -import '../test_support.dart'; - -void main() { - testWidgets( - 'GatewayConnectDialog switches between setup and manual connection controls', - (WidgetTester tester) async { - final controller = await createTestController(tester); - - await pumpPage( - tester, - child: GatewayConnectDialog(controller: controller, compact: true), - ); - - expect(find.text('Gateway 访问'), findsOneWidget); - expect(find.text('配置码'), findsWidgets); - - await tester.tap(find.text('手动配置')); - await tester.pumpAndSettle(); - - expect(find.text('工作模式'), findsOneWidget); - expect(find.text('主机'), findsOneWidget); - expect(find.text('端口'), findsOneWidget); - expect(find.text('TLS'), findsOneWidget); - expect(find.text('共享 Token'), findsOneWidget); - expect(find.text('认证诊断'), findsOneWidget); - expect(find.textContaining('fields: none'), findsOneWidget); - expect(find.textContaining('开发预填 token'), findsNothing); - - await tester.tap( - find.byType(DropdownButtonFormField), - ); - await tester.pumpAndSettle(); - - expect(find.text('仅 AI Gateway'), findsWidgets); - expect(find.text('本地 OpenClaw Gateway'), findsWidgets); - expect(find.text('远程 OpenClaw Gateway'), findsWidgets); - - await tester.tap(find.text('仅 AI Gateway').last); - await tester.pumpAndSettle(); - - expect(find.text('应用模式'), findsOneWidget); - expect( - find.text('当前模式仅通过 AI Gateway 处理任务,不会建立 OpenClaw Gateway 会话。'), - findsOneWidget, - ); - expect(_textFieldByLabel(tester, '主机').enabled, isFalse); - expect(_textFieldByLabel(tester, '端口').enabled, isFalse); - expect(_textFieldByLabel(tester, '共享 Token').enabled, isFalse); - expect(_textFieldByLabel(tester, '密码').enabled, isFalse); - }, - ); -} - -TextField _textFieldByLabel(WidgetTester tester, String label) { - return tester - .widgetList(find.byType(TextField)) - .firstWhere((field) => field.decoration?.labelText == label); -} diff --git a/test/widgets/gateway_connect_dialog_test.dart b/test/widgets/gateway_connect_dialog_test.dart deleted file mode 100644 index 9a2d0d18..00000000 --- a/test/widgets/gateway_connect_dialog_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import '../test_suite_stub.dart' - if (dart.library.io) 'gateway_connect_dialog_suite.dart' - as suite; - -void main() { - suite.main(); -}