From c679d6a13fec996d98d8bd0ddfa5d7b9fa8f1491 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 19 Mar 2026 15:45:06 +0800 Subject: [PATCH] Unify task dialog gateway modes --- lib/app/app_controller.dart | 71 ++++++++++- lib/features/assistant/assistant_page.dart | 3 +- lib/runtime/runtime_models.dart | 17 ++- lib/widgets/gateway_connect_dialog.dart | 112 ++++++++++++++++-- test/features/assistant_page_test.dart | 49 ++++++-- ...ntroller_execution_target_switch_test.dart | 67 +++++++++-- test/widgets/gateway_connect_dialog_test.dart | 32 ++++- 7 files changed, 307 insertions(+), 44 deletions(-) diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 9ef3133d..172bf084 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -534,7 +534,12 @@ class AppController extends ChangeNotifier { mode: _modeFromHost(decoded?.host ?? settings.gateway.host), ); await saveSettings( - settings.copyWith(gateway: nextProfile), + settings.copyWith( + gateway: nextProfile, + assistantExecutionTarget: _assistantExecutionTargetForMode( + nextProfile.mode, + ), + ), refreshAfterSave: false, ); await _connectProfile( @@ -572,7 +577,12 @@ class AppController extends ChangeNotifier { tls: mode == RuntimeConnectionMode.local ? false : tls, ); await saveSettings( - settings.copyWith(gateway: nextProfile), + settings.copyWith( + gateway: nextProfile, + assistantExecutionTarget: _assistantExecutionTargetForMode( + nextProfile.mode, + ), + ), refreshAfterSave: false, ); await _connectProfile( @@ -739,6 +749,30 @@ class AppController extends ChangeNotifier { if (settings.assistantExecutionTarget == target) { return; } + if (target == AssistantExecutionTarget.aiGatewayOnly) { + final nextGatewayProfile = settings.gateway.copyWith( + mode: RuntimeConnectionMode.unconfigured, + useSetupCode: false, + setupCode: '', + ); + await saveSettings( + settings.copyWith( + assistantExecutionTarget: target, + gateway: nextGatewayProfile, + ), + refreshAfterSave: false, + ); + if (_runtime.isConnected) { + try { + await disconnectGateway(); + } catch (_) { + // Preserve the selected AI Gateway-only mode even if the active + // gateway session does not close cleanly on the first attempt. + } + } + return; + } + await saveSettings( settings.copyWith(assistantExecutionTarget: target), refreshAfterSave: false, @@ -1460,10 +1494,31 @@ class AppController extends ChangeNotifier { return RuntimeConnectionMode.remote; } + AssistantExecutionTarget _assistantExecutionTargetForMode( + RuntimeConnectionMode mode, + ) { + return switch (mode) { + RuntimeConnectionMode.unconfigured => + AssistantExecutionTarget.aiGatewayOnly, + RuntimeConnectionMode.local => AssistantExecutionTarget.local, + RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, + }; + } + GatewayConnectionProfile _gatewayProfileForAssistantExecutionTarget( AssistantExecutionTarget target, ) { + if (target == AssistantExecutionTarget.aiGatewayOnly) { + return settings.gateway.copyWith( + mode: RuntimeConnectionMode.unconfigured, + useSetupCode: false, + setupCode: '', + ); + } + final desiredMode = switch (target) { + AssistantExecutionTarget.aiGatewayOnly => + RuntimeConnectionMode.unconfigured, AssistantExecutionTarget.local => RuntimeConnectionMode.local, AssistantExecutionTarget.remote => RuntimeConnectionMode.remote, }; @@ -1484,13 +1539,19 @@ class AppController extends ChangeNotifier { } final defaults = GatewayConnectionProfile.defaults(); + final savedHost = savedProfile.host.trim().isEmpty + ? defaults.host + : savedProfile.host.trim(); + final savedPort = savedProfile.port <= 0 + ? defaults.port + : savedProfile.port; return savedProfile.copyWith( mode: RuntimeConnectionMode.remote, useSetupCode: false, setupCode: '', - host: defaults.host, - port: defaults.port, - tls: defaults.tls, + host: savedHost, + port: savedPort, + tls: savedProfile.tls, ); } } diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 8349c842..6a63b633 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -2073,7 +2073,7 @@ class _ComposerBar extends StatelessWidget { const SizedBox(width: 6), PopupMenuButton( key: const Key('assistant-execution-target-button'), - tooltip: appText('本地或远程', 'Local or remote'), + tooltip: appText('任务对话模式', 'Task Dialog Mode'), onSelected: (value) { controller.setAssistantExecutionTarget(value); }, @@ -2608,6 +2608,7 @@ class _ComposerToolbarChipState extends State<_ComposerToolbarChip> { extension on AssistantExecutionTarget { IconData get icon => switch (this) { + AssistantExecutionTarget.aiGatewayOnly => Icons.hub_outlined, AssistantExecutionTarget.local => Icons.computer_outlined, AssistantExecutionTarget.remote => Icons.cloud_outlined, }; diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 4e92f702..d8791d8d 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -31,15 +31,26 @@ extension RuntimeConnectionStatusCopy on RuntimeConnectionStatus { }; } -enum AssistantExecutionTarget { local, remote } +enum AssistantExecutionTarget { aiGatewayOnly, local, remote } extension AssistantExecutionTargetCopy on AssistantExecutionTarget { String get label => switch (this) { - AssistantExecutionTarget.local => appText('本地', 'Local'), - AssistantExecutionTarget.remote => appText('远程', 'Remote'), + AssistantExecutionTarget.aiGatewayOnly => appText( + '仅 AI Gateway', + 'AI Gateway Only', + ), + AssistantExecutionTarget.local => appText( + '本地 OpenClaw Gateway', + 'Local OpenClaw Gateway', + ), + AssistantExecutionTarget.remote => appText( + '远程 OpenClaw Gateway', + 'Remote OpenClaw Gateway', + ), }; String get promptValue => switch (this) { + AssistantExecutionTarget.aiGatewayOnly => 'ai-gateway-only', AssistantExecutionTarget.local => 'local', AssistantExecutionTarget.remote => 'remote', }; diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index f223ec81..9f75edfd 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -38,6 +38,32 @@ class _GatewayConnectDialogState extends State { 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(); @@ -93,6 +119,9 @@ class _GatewayConnectDialogState extends State { 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( @@ -119,8 +148,8 @@ class _GatewayConnectDialogState extends State { const SizedBox(height: AppSpacing.section), Text( appText( - '通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。', - 'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS.', + '通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。也可切换到仅 AI Gateway 模式,仅使用模型路由而不建立 Gateway 会话。', + 'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS. You can also switch to AI Gateway Only mode to use model routing without opening a gateway session.', ), style: supportingCopyStyle, ), @@ -159,13 +188,13 @@ class _GatewayConnectDialogState extends State { DropdownButtonFormField( initialValue: _connectionMode, decoration: InputDecoration( - labelText: appText('连接模式', 'Connection Mode'), + labelText: appText('工作模式', 'Work Mode'), ), items: RuntimeConnectionMode.values .map( (mode) => DropdownMenuItem( value: mode, - child: Text(mode.label), + child: Text(_connectionModeLabel(mode)), ), ) .toList(), @@ -183,9 +212,24 @@ class _GatewayConnectDialogState extends State { }); }, ), + 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), @@ -196,6 +240,7 @@ class _GatewayConnectDialogState extends State { flex: 3, child: TextField( controller: _portController, + enabled: _manualGatewayFieldsEnabled, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: appText('端口', 'Port'), @@ -208,8 +253,12 @@ class _GatewayConnectDialogState extends State { child: _TlsToggleCard( value: _tls, label: appText('TLS', 'TLS'), - enabled: _connectionMode != RuntimeConnectionMode.local, - onChanged: _connectionMode == RuntimeConnectionMode.local + enabled: + _manualGatewayFieldsEnabled && + _connectionMode != RuntimeConnectionMode.local, + onChanged: + !_manualGatewayFieldsEnabled || + _connectionMode == RuntimeConnectionMode.local ? null : (value) => setState(() => _tls = value), ), @@ -222,6 +271,7 @@ class _GatewayConnectDialogState extends State { const SizedBox(height: 8), TextField( controller: _tokenController, + enabled: _credentialFieldsEnabled, obscureText: _obscureSharedToken, enableSuggestions: false, autocorrect: false, @@ -235,9 +285,11 @@ class _GatewayConnectDialogState extends State { tooltip: _obscureSharedToken ? appText('显示 Token', 'Show token') : appText('隐藏 Token', 'Hide token'), - onPressed: () => setState( - () => _obscureSharedToken = !_obscureSharedToken, - ), + onPressed: !_credentialFieldsEnabled + ? null + : () => setState( + () => _obscureSharedToken = !_obscureSharedToken, + ), icon: Icon( _obscureSharedToken ? Icons.visibility_off_rounded @@ -247,7 +299,7 @@ class _GatewayConnectDialogState extends State { ), onChanged: (_) => setState(() {}), ), - if (willUseStoredGatewayToken || typedGatewayToken.isNotEmpty) ...[ + if (showSharedTokenStatusCard) ...[ const SizedBox(height: 10), _SharedTokenStatusCard( hasStoredGatewayToken: hasStoredGatewayToken, @@ -268,6 +320,7 @@ class _GatewayConnectDialogState extends State { const SizedBox(height: 12), TextField( controller: _passwordController, + enabled: _credentialFieldsEnabled, obscureText: true, decoration: InputDecoration( labelText: appText('密码', 'Password'), @@ -300,8 +353,12 @@ class _GatewayConnectDialogState extends State { icon: const Icon(Icons.wifi_tethering_rounded), label: Text( _submitting - ? appText('连接中…', 'Connecting…') - : appText('连接', 'Connect'), + ? (_isAiGatewayOnlyMode + ? appText('应用中…', 'Applying…') + : appText('连接中…', 'Connecting…')) + : (_isAiGatewayOnlyMode + ? appText('应用模式', 'Apply Mode') + : appText('连接', 'Connect')), ), ), ), @@ -342,7 +399,9 @@ class _GatewayConnectDialogState extends State { profile.port == defaults.port; setState(() { if (shouldPrefillEndpoint) { - _connectionMode = preferred.mode; + if (_connectionMode != RuntimeConnectionMode.unconfigured) { + _connectionMode = preferred.mode; + } _hostController.text = preferred.host; _portController.text = '${preferred.port}'; _tls = preferred.tls; @@ -368,6 +427,33 @@ class _GatewayConnectDialogState extends State { token: resolvedToken, password: _passwordController.text, ); + } else if (_connectionMode == RuntimeConnectionMode.unconfigured) { + final currentSettings = widget.controller.settings; + final currentProfile = currentSettings.gateway; + final resolvedHost = _hostController.text.trim().isEmpty + ? currentProfile.host + : _hostController.text.trim(); + final resolvedPort = + int.tryParse(_portController.text.trim()) ?? currentProfile.port; + final nextProfile = currentProfile.copyWith( + mode: RuntimeConnectionMode.unconfigured, + useSetupCode: false, + setupCode: '', + host: resolvedHost, + port: resolvedPort <= 0 ? currentProfile.port : resolvedPort, + tls: _tls, + ); + await widget.controller.saveSettings( + currentSettings.copyWith( + gateway: nextProfile, + assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly, + ), + refreshAfterSave: false, + ); + if (widget.controller.connection.status == + RuntimeConnectionStatus.connected) { + await widget.controller.disconnectGateway(); + } } else { await widget.controller.connectManual( host: _hostController.text, diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index 2a8befcc..ed9ff736 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/features/assistant/assistant_page.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; import '../test_support.dart'; @@ -174,16 +175,27 @@ void main() { child: AssistantPage(controller: controller, onOpenDetail: (_) {}), ); - expect(find.textContaining('Claw'), findsNothing); expect(find.text('幻灯片'), findsNothing); expect(find.text('视频生成'), findsNothing); expect(find.text('深度研究'), findsNothing); expect(find.text('自动化'), findsNothing); expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); - expect(find.byKey(const Key('assistant-attachment-menu-button')), findsOneWidget); - expect(find.byKey(const Key('assistant-execution-target-button')), findsOneWidget); - expect(find.byKey(const Key('assistant-skill-picker-button')), findsOneWidget); - expect(find.byKey(const Key('assistant-permission-button')), findsOneWidget); + expect( + find.byKey(const Key('assistant-attachment-menu-button')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-button')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-skill-picker-button')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-permission-button')), + findsOneWidget, + ); expect(find.byKey(const Key('assistant-model-button')), findsOneWidget); expect(find.byKey(const Key('assistant-thinking-button')), findsOneWidget); expect(find.byTooltip('模式'), findsNothing); @@ -199,11 +211,22 @@ void main() { await tester.tapAt(const Offset(24, 24)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('assistant-execution-target-button'))); + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); await tester.pumpAndSettle(); - expect(find.text('本地'), findsWidgets); - expect(find.text('远程'), findsOneWidget); + expect(find.text('仅 AI Gateway'), findsOneWidget); + expect(find.text('本地 OpenClaw Gateway'), findsWidgets); + expect(find.text('远程 OpenClaw Gateway'), findsOneWidget); + + await tester.tap(find.text('仅 AI Gateway').last); + await tester.pumpAndSettle(); + + expect( + controller.assistantExecutionTarget, + AssistantExecutionTarget.aiGatewayOnly, + ); await tester.tapAt(const Offset(24, 24)); await tester.pumpAndSettle(); @@ -214,8 +237,14 @@ void main() { await tester.tap(find.byKey(const Key('assistant-skill-picker-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('assistant-skill-picker-dialog')), findsOneWidget); - expect(find.byKey(const Key('assistant-skill-picker-search')), findsOneWidget); + expect( + find.byKey(const Key('assistant-skill-picker-dialog')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-skill-picker-search')), + findsOneWidget, + ); expect(find.text('1password'), findsOneWidget); expect(find.text('xlsx'), findsOneWidget); expect(find.text('网页处理'), findsOneWidget); diff --git a/test/runtime/app_controller_execution_target_switch_test.dart b/test/runtime/app_controller_execution_target_switch_test.dart index 80895acd..d1d6ce78 100644 --- a/test/runtime/app_controller_execution_target_switch_test.dart +++ b/test/runtime/app_controller_execution_target_switch_test.dart @@ -17,6 +17,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { final List connectedProfiles = []; + int disconnectCount = 0; GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(); @override @@ -35,18 +36,18 @@ class _FakeGatewayRuntime extends GatewayRuntime { String authPasswordOverride = '', }) async { connectedProfiles.add(profile); - _snapshot = - GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( - status: RuntimeConnectionStatus.connected, - statusText: 'Connected', - remoteAddress: '${profile.host}:${profile.port}', - connectAuthMode: 'none', - ); + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: '${profile.host}:${profile.port}', + connectAuthMode: 'none', + ); notifyListeners(); } @override Future disconnect({bool clearDesiredProfile = true}) async { + disconnectCount += 1; _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.offline, statusText: 'Offline', @@ -65,10 +66,7 @@ class _FakeGatewayRuntime extends GatewayRuntime { case 'status': return {'ok': true}; case 'agents.list': - return { - 'agents': const [], - 'mainKey': 'main', - }; + return {'agents': const [], 'mainKey': 'main'}; case 'sessions.list': return {'sessions': const []}; case 'chat.history': @@ -200,6 +198,53 @@ void main() { ); expect(controller.settings.gateway.port, 9443); expect(controller.settings.gateway.mode, RuntimeConnectionMode.remote); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, + ); + + expect( + controller.settings.assistantExecutionTarget, + AssistantExecutionTarget.aiGatewayOnly, + ); + expect( + controller.settings.gateway.mode, + RuntimeConnectionMode.unconfigured, + ); + expect(controller.settings.gateway.useSetupCode, isFalse); + expect(controller.settings.gateway.setupCode, isEmpty); + expect( + controller.settings.gateway.host, + 'gateway.example.com', + reason: + 'AI Gateway-only mode should preserve the saved remote endpoint.', + ); + expect(controller.settings.gateway.port, 9443); + expect(controller.settings.gateway.tls, isTrue); + expect(gateway.disconnectCount, 1); + expect( + gateway.connectedProfiles, + hasLength(2), + reason: 'AI Gateway-only mode should not open another gateway session.', + ); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + + expect( + gateway.connectedProfiles.last, + isA() + .having((item) => item.mode, 'mode', RuntimeConnectionMode.remote) + .having((item) => item.host, 'host', 'gateway.example.com') + .having((item) => item.port, 'port', 9443) + .having((item) => item.tls, 'tls', isTrue) + .having( + (item) => item.selectedAgentId, + 'selectedAgentId', + 'assistant-main', + ), + ); }, ); } diff --git a/test/widgets/gateway_connect_dialog_test.dart b/test/widgets/gateway_connect_dialog_test.dart index 1bb7f883..2f3e0071 100644 --- a/test/widgets/gateway_connect_dialog_test.dart +++ b/test/widgets/gateway_connect_dialog_test.dart @@ -1,4 +1,6 @@ +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'; @@ -20,7 +22,7 @@ void main() { await tester.tap(find.text('手动配置')); await tester.pumpAndSettle(); - expect(find.text('连接模式'), findsOneWidget); + expect(find.text('工作模式'), findsOneWidget); expect(find.text('主机'), findsOneWidget); expect(find.text('端口'), findsOneWidget); expect(find.text('TLS'), findsOneWidget); @@ -28,6 +30,34 @@ void main() { 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); +}