Integrate gateway settings into integrations page

This commit is contained in:
Haitao Pan 2026-03-22 17:07:27 +08:00
parent ee3f9ec80b
commit 93032366bd
14 changed files with 1161 additions and 1275 deletions

1
dart_test.yaml Normal file
View File

@ -0,0 +1 @@
concurrency: 1

View File

@ -86,7 +86,10 @@ class AppController extends ChangeNotifier {
_desktopPlatformService =
desktopPlatformService ?? createDesktopPlatformService();
_gatewayOnlySkillScanRoots =
gatewayOnlySkillScanRoots ?? _defaultGatewayOnlySkillScanRoots;
gatewayOnlySkillScanRoots ??
(_isFlutterTestEnvironment
? const <String>[]
: _defaultGatewayOnlySkillScanRoots);
_arisBundleRepository = ArisBundleRepository();
_arisBridgeLocator = ArisBridgeLocator();
_multiAgentMountManager = MultiAgentMountManager(
@ -169,6 +172,9 @@ class AppController extends ChangeNotifier {
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false;
static bool get _isFlutterTestEnvironment =>
Platform.environment.containsKey('FLUTTER_TEST');
Future<void> _assistantThreadPersistQueue = Future<void>.value();
WorkspaceDestination get destination => _destination;
@ -2077,10 +2083,91 @@ class AppController extends ChangeNotifier {
return _settingsController.testOllamaConnection(cloud: cloud);
}
Future<String> testOllamaConnectionDraft({
required bool cloud,
required SettingsSnapshot snapshot,
String apiKeyOverride = '',
}) {
return _settingsController.testOllamaConnectionDraft(
cloud: cloud,
localConfig: snapshot.ollamaLocal,
cloudConfig: snapshot.ollamaCloud,
apiKeyOverride: apiKeyOverride,
);
}
Future<String> testVaultConnection() {
return _settingsController.testVaultConnection();
}
Future<String> 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);

View File

@ -426,6 +426,43 @@ class AppController extends ChangeNotifier {
_saveSecretDraft(_draftOllamaApiKeyKey, value);
}
Future<String> testOllamaConnection({required bool cloud}) async {
return cloud ? 'Cloud test unavailable on web' : 'Local test unavailable on web';
}
Future<String> testOllamaConnectionDraft({
required bool cloud,
required SettingsSnapshot snapshot,
String apiKeyOverride = '',
}) async {
return testOllamaConnection(cloud: cloud);
}
Future<String> testVaultConnection() async {
return 'Vault test unavailable on web';
}
Future<String> 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<void> persistSettingsDraft() async {
if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText(

View File

@ -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<AssistantPage> {
scrollController: _conversationController,
onOpenDetail: widget.onOpenDetail,
onFocusComposer: _focusComposer,
onOpenGateway: _showConnectDialog,
onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onMessageViewModeChanged:
@ -463,7 +462,7 @@ class _AssistantPageState extends State<AssistantPage> {
controller.currentSessionKey,
modelId,
),
onOpenGateway: _showConnectDialog,
onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments,
@ -878,19 +877,20 @@ class _AssistantPageState extends State<AssistantPage> {
};
}
void _showConnectDialog() {
showDialog<void>(
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<void> _connectFromSavedSettingsOrShowDialog() async {
if (!widget.controller.canQuickConnectGateway) {
_showConnectDialog();
_openGatewaySettings();
return;
}
await widget.controller.connectSavedGateway();

View File

@ -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<MobileShell> {
}
void _showConnectSheet() {
showModalBottomSheet<void>(
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)

File diff suppressed because it is too large Load Diff

View File

@ -215,15 +215,31 @@ class SettingsController extends ChangeNotifier {
}
Future<String> testOllamaConnection({required bool cloud}) async {
return testOllamaConnectionDraft(
cloud: cloud,
localConfig: _snapshot.ollamaLocal,
cloudConfig: _snapshot.ollamaCloud,
);
}
Future<String> 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
? <String, String>{
if (_secureRefs[_snapshot.ollamaCloud.apiKeyRef] != null)
if (cloudApiKey.isNotEmpty)
'Authorization': 'Bearer live-secret',
}
: const <String, String>{},
@ -252,7 +268,14 @@ class SettingsController extends ChangeNotifier {
}
Future<String> testVaultConnection() async {
final address = _snapshot.vault.address.trim();
return testVaultConnectionDraft(_snapshot.vault);
}
Future<String> 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 = <String, String>{
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);

View File

@ -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<GatewayConnectDialog> createState() => _GatewayConnectDialogState();
}
class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
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<RuntimeConnectionMode>(
initialValue: _connectionMode,
decoration: InputDecoration(
labelText: appText('工作模式', 'Work Mode'),
),
items: availableConnectionModes
.map(
(mode) => DropdownMenuItem<RuntimeConnectionMode>(
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<void> _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<void> _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<RuntimeConnectionMode> _availableConnectionModes(
UiFeatureAccess uiFeatures,
) {
return <RuntimeConnectionMode>[
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<void> 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<bool>? 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),
],
),
);
}
}

View File

@ -56,6 +56,16 @@ class _FakeCodexRuntime extends CodexRuntime {
Future<void> stop() async {}
}
class _AiGatewayPageTestController extends AppController {
_AiGatewayPageTestController({
required super.store,
required super.runtimeCoordinator,
});
@override
Future<void> 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(<String, Object>{});
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);

View File

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

View File

@ -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(<String, Object>{});
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 <String>['stale-model'],
selectedModels: const <String>['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 <String>['stale-model'],
selectedModels: const <String>['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<OutlinedButton>(
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<FilledButton>(
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<void> refreshMultiAgentMounts({bool sync = false}) async {}
}
Future<void> _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<void>.delayed(const Duration(milliseconds: 20));
}

View File

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

View File

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

View File

@ -1,7 +0,0 @@
import '../test_suite_stub.dart'
if (dart.library.io) 'gateway_connect_dialog_suite.dart'
as suite;
void main() {
suite.main();
}