Unify task dialog gateway modes

This commit is contained in:
Haitao Pan 2026-03-19 15:45:06 +08:00
parent 09ef2ea4f5
commit c679d6a13f
7 changed files with 307 additions and 44 deletions

View File

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

View File

@ -2073,7 +2073,7 @@ class _ComposerBar extends StatelessWidget {
const SizedBox(width: 6),
PopupMenuButton<AssistantExecutionTarget>(
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,
};

View File

@ -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',
};

View File

@ -38,6 +38,32 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
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<GatewayConnectDialog> {
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<GatewayConnectDialog> {
DropdownButtonFormField<RuntimeConnectionMode>(
initialValue: _connectionMode,
decoration: InputDecoration(
labelText: appText('连接模式', 'Connection Mode'),
labelText: appText('工作模式', 'Work Mode'),
),
items: RuntimeConnectionMode.values
.map(
(mode) => DropdownMenuItem<RuntimeConnectionMode>(
value: mode,
child: Text(mode.label),
child: Text(_connectionModeLabel(mode)),
),
)
.toList(),
@ -183,9 +212,24 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
});
},
),
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<GatewayConnectDialog> {
flex: 3,
child: TextField(
controller: _portController,
enabled: _manualGatewayFieldsEnabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: appText('端口', 'Port'),
@ -208,8 +253,12 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
const SizedBox(height: 8),
TextField(
controller: _tokenController,
enabled: _credentialFieldsEnabled,
obscureText: _obscureSharedToken,
enableSuggestions: false,
autocorrect: false,
@ -235,9 +285,11 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
),
onChanged: (_) => setState(() {}),
),
if (willUseStoredGatewayToken || typedGatewayToken.isNotEmpty) ...[
if (showSharedTokenStatusCard) ...[
const SizedBox(height: 10),
_SharedTokenStatusCard(
hasStoredGatewayToken: hasStoredGatewayToken,
@ -268,6 +320,7 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
const SizedBox(height: 12),
TextField(
controller: _passwordController,
enabled: _credentialFieldsEnabled,
obscureText: true,
decoration: InputDecoration(
labelText: appText('密码', 'Password'),
@ -300,8 +353,12 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<GatewayConnectDialog> {
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<GatewayConnectDialog> {
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,

View File

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

View File

@ -17,6 +17,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
final List<GatewayConnectionProfile> connectedProfiles =
<GatewayConnectionProfile>[];
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<void> 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 <String, dynamic>{'ok': true};
case 'agents.list':
return <String, dynamic>{
'agents': const <Object>[],
'mainKey': 'main',
};
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
case 'sessions.list':
return <String, dynamic>{'sessions': const <Object>[]};
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<GatewayConnectionProfile>()
.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',
),
);
},
);
}

View File

@ -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<RuntimeConnectionMode>),
);
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<TextField>(find.byType(TextField))
.firstWhere((field) => field.decoration?.labelText == label);
}