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 =
desktopPlatformService ?? createDesktopPlatformService(); desktopPlatformService ?? createDesktopPlatformService();
_gatewayOnlySkillScanRoots = _gatewayOnlySkillScanRoots =
gatewayOnlySkillScanRoots ?? _defaultGatewayOnlySkillScanRoots; gatewayOnlySkillScanRoots ??
(_isFlutterTestEnvironment
? const <String>[]
: _defaultGatewayOnlySkillScanRoots);
_arisBundleRepository = ArisBundleRepository(); _arisBundleRepository = ArisBundleRepository();
_arisBridgeLocator = ArisBridgeLocator(); _arisBridgeLocator = ArisBridgeLocator();
_multiAgentMountManager = MultiAgentMountManager( _multiAgentMountManager = MultiAgentMountManager(
@ -169,6 +172,9 @@ class AppController extends ChangeNotifier {
String? _bootstrapError; String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription; StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false; bool _disposed = false;
static bool get _isFlutterTestEnvironment =>
Platform.environment.containsKey('FLUTTER_TEST');
Future<void> _assistantThreadPersistQueue = Future<void>.value(); Future<void> _assistantThreadPersistQueue = Future<void>.value();
WorkspaceDestination get destination => _destination; WorkspaceDestination get destination => _destination;
@ -2077,10 +2083,91 @@ class AppController extends ChangeNotifier {
return _settingsController.testOllamaConnection(cloud: cloud); 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() { Future<String> testVaultConnection() {
return _settingsController.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() { void clearRuntimeLogs() {
_runtimeCoordinator.gateway.clearLogs(); _runtimeCoordinator.gateway.clearLogs();
_notifyIfActive(); _notifyIfActive();
@ -2274,7 +2361,10 @@ class AppController extends ChangeNotifier {
// Keep the shell usable when auto-connect fails. // 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; _settingsDraft = settings;
_lastAppliedSettings = settings; _lastAppliedSettings = settings;
_settingsDraftInitialized = true; _settingsDraftInitialized = true;
@ -3337,7 +3427,13 @@ class AppController extends ChangeNotifier {
if (_disposed) { if (_disposed) {
return; return;
} }
try {
await _store.saveAssistantThreadRecords(snapshot); 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; _assistantThreadPersistQueue = nextPersist;
unawaited(nextPersist); unawaited(nextPersist);

View File

@ -426,6 +426,43 @@ class AppController extends ChangeNotifier {
_saveSecretDraft(_draftOllamaApiKeyKey, value); _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 { Future<void> persistSettingsDraft() async {
if (!hasSettingsDraftChanges) { if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText( _settingsDraftStatusMessage = appText(

View File

@ -18,7 +18,6 @@ import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart'; import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart'; import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/gateway_connect_dialog.dart';
import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart'; import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart'; import '../../widgets/surface_card.dart';
@ -401,7 +400,7 @@ class _AssistantPageState extends State<AssistantPage> {
scrollController: _conversationController, scrollController: _conversationController,
onOpenDetail: widget.onOpenDetail, onOpenDetail: widget.onOpenDetail,
onFocusComposer: _focusComposer, onFocusComposer: _focusComposer,
onOpenGateway: _showConnectDialog, onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings, onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onMessageViewModeChanged: onMessageViewModeChanged:
@ -463,7 +462,7 @@ class _AssistantPageState extends State<AssistantPage> {
controller.currentSessionKey, controller.currentSessionKey,
modelId, modelId,
), ),
onOpenGateway: _showConnectDialog, onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings, onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments, onPickAttachments: _pickAttachments,
@ -878,19 +877,20 @@ class _AssistantPageState extends State<AssistantPage> {
}; };
} }
void _showConnectDialog() { void _openGatewaySettings() {
showDialog<void>( widget.controller.openSettings(
context: context, detail: SettingsDetailPage.gatewayConnection,
builder: (context) => GatewayConnectDialog( navigationContext: SettingsNavigationContext(
controller: widget.controller, rootLabel: appText('助手', 'Assistant'),
onDone: () => Navigator.of(context).pop(), destination: WorkspaceDestination.assistant,
sectionLabel: appText('集成', 'Integrations'),
), ),
); );
} }
Future<void> _connectFromSavedSettingsOrShowDialog() async { Future<void> _connectFromSavedSettingsOrShowDialog() async {
if (!widget.controller.canQuickConnectGateway) { if (!widget.controller.canQuickConnectGateway) {
_showConnectDialog(); _openGatewaySettings();
return; return;
} }
await widget.controller.connectSavedGateway(); await widget.controller.connectSavedGateway();

View File

@ -11,7 +11,6 @@ import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart'; import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../widgets/detail_drawer.dart'; import '../../widgets/detail_drawer.dart';
import '../../widgets/gateway_connect_dialog.dart';
enum MobileShellTab { assistant, tasks, workspace, secrets, settings } enum MobileShellTab { assistant, tasks, workspace, secrets, settings }
@ -147,20 +146,14 @@ class _MobileShellState extends State<MobileShell> {
} }
void _showConnectSheet() { void _showConnectSheet() {
showModalBottomSheet<void>( widget.controller.openSettings(
context: context, detail: SettingsDetailPage.gatewayConnection,
isScrollControlled: true, navigationContext: SettingsNavigationContext(
backgroundColor: Colors.transparent, rootLabel: appText('移动端', 'Mobile'),
builder: (sheetContext) { destination: WorkspaceDestination.settings,
return FractionallySizedBox( sectionLabel: appText('集成', 'Integrations'),
heightFactor: 0.94,
child: GatewayConnectDialog(
controller: widget.controller,
onDone: () => Navigator.of(sheetContext).pop(),
), ),
); );
},
);
} }
void _showMobileSafeSheet() { void _showMobileSafeSheet() {
@ -689,7 +682,7 @@ class _MobileSafeSheet extends StatelessWidget {
child: Text( child: Text(
controller.canQuickConnectGateway controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect') ? appText('快速连接', 'Quick Connect')
: appText('打开连接面板', 'Open Connection'), : appText('打开集成设置', 'Open Integrations'),
), ),
), ),
if (hasPendingRun) if (hasPendingRun)

View File

@ -9,9 +9,9 @@ import '../../app/workspace_navigation.dart';
import '../ai_gateway/ai_gateway_page.dart'; import '../ai_gateway/ai_gateway_page.dart';
import '../../i18n/app_language.dart'; import '../../i18n/app_language.dart';
import '../../models/app_models.dart'; import '../../models/app_models.dart';
import '../../runtime/gateway_runtime.dart';
import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart'; import '../../runtime/runtime_models.dart';
import '../../widgets/gateway_connect_dialog.dart';
import '../../widgets/section_tabs.dart'; import '../../widgets/section_tabs.dart';
import '../../widgets/surface_card.dart'; import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart'; import '../../widgets/top_bar.dart';
@ -45,9 +45,24 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _aiGatewayApiKeyRefController; late final TextEditingController _aiGatewayApiKeyRefController;
late final TextEditingController _aiGatewayApiKeyController; late final TextEditingController _aiGatewayApiKeyController;
late final TextEditingController _aiGatewayModelSearchController; 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 _vaultTokenController;
late final TextEditingController _ollamaApiKeyController; late final TextEditingController _ollamaApiKeyController;
late final TextEditingController _runtimeLogFilterController; 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; bool _aiGatewayTesting = false;
String _aiGatewayTestState = 'idle'; String _aiGatewayTestState = 'idle';
String _aiGatewayTestMessage = ''; String _aiGatewayTestMessage = '';
@ -70,6 +85,11 @@ class _SettingsPageState extends State<SettingsPage> {
_aiGatewayApiKeyRefController = TextEditingController(); _aiGatewayApiKeyRefController = TextEditingController();
_aiGatewayApiKeyController = TextEditingController(); _aiGatewayApiKeyController = TextEditingController();
_aiGatewayModelSearchController = TextEditingController(); _aiGatewayModelSearchController = TextEditingController();
_gatewaySetupCodeController = TextEditingController();
_gatewayHostController = TextEditingController();
_gatewayPortController = TextEditingController();
_gatewayTokenController = TextEditingController();
_gatewayPasswordController = TextEditingController();
_vaultTokenController = TextEditingController(); _vaultTokenController = TextEditingController();
_ollamaApiKeyController = TextEditingController(); _ollamaApiKeyController = TextEditingController();
_runtimeLogFilterController = TextEditingController(); _runtimeLogFilterController = TextEditingController();
@ -96,6 +116,11 @@ class _SettingsPageState extends State<SettingsPage> {
_aiGatewayApiKeyRefController.dispose(); _aiGatewayApiKeyRefController.dispose();
_aiGatewayApiKeyController.dispose(); _aiGatewayApiKeyController.dispose();
_aiGatewayModelSearchController.dispose(); _aiGatewayModelSearchController.dispose();
_gatewaySetupCodeController.dispose();
_gatewayHostController.dispose();
_gatewayPortController.dispose();
_gatewayTokenController.dispose();
_gatewayPasswordController.dispose();
_vaultTokenController.dispose(); _vaultTokenController.dispose();
_ollamaApiKeyController.dispose(); _ollamaApiKeyController.dispose();
_runtimeLogFilterController.dispose(); _runtimeLogFilterController.dispose();
@ -224,7 +249,6 @@ class _SettingsPageState extends State<SettingsPage> {
SettingsSnapshot settings, SettingsSnapshot settings,
SettingsDetailPage detail, SettingsDetailPage detail,
) { ) {
final gatewaySections = _buildGateway(context, controller, settings);
final workspaceSections = _buildWorkspace(context, controller, settings); final workspaceSections = _buildWorkspace(context, controller, settings);
return switch (detail) { return switch (detail) {
SettingsDetailPage.gatewayConnection => <Widget>[ SettingsDetailPage.gatewayConnection => <Widget>[
@ -237,7 +261,11 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
const SizedBox(height: 16), 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 => <Widget>[ SettingsDetailPage.aiGatewayIntegration => <Widget>[
_buildDetailIntro( _buildDetailIntro(
@ -249,7 +277,7 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (gatewaySections.isNotEmpty) gatewaySections.last, _buildAiGatewayCard(context, controller, settings),
], ],
SettingsDetailPage.vaultProvider => <Widget>[ SettingsDetailPage.vaultProvider => <Widget>[
_buildDetailIntro( _buildDetailIntro(
@ -261,7 +289,7 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (gatewaySections.length > 4) gatewaySections[4], _buildVaultProviderCard(context, controller, settings),
], ],
SettingsDetailPage.ollamaProvider => <Widget>[ SettingsDetailPage.ollamaProvider => <Widget>[
_buildDetailIntro( _buildDetailIntro(
@ -858,109 +886,317 @@ class _SettingsPageState extends State<SettingsPage> {
AppController controller, AppController controller,
SettingsSnapshot settings, SettingsSnapshot settings,
) { ) {
_syncDraftControllerValue(
_aiGatewayNameController,
settings.aiGateway.name,
syncedValue: _aiGatewayNameSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayNameSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayUrlController,
settings.aiGateway.baseUrl,
syncedValue: _aiGatewayUrlSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayUrlSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayApiKeyRefController,
settings.aiGateway.apiKeyRef,
syncedValue: _aiGatewayApiKeyRefSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayApiKeyRefSyncedValue = value,
);
final selectedModels = settings.aiGateway.selectedModels.isNotEmpty
? settings.aiGateway.selectedModels
: settings.aiGateway.availableModels.take(5).toList(growable: false);
final filteredModels = _filterAiGatewayModels(
settings.aiGateway.availableModels,
);
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 [ return [
SurfaceCard( _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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'OpenClaw Gateway', 'OpenClaw Gateway',
style: Theme.of(context).textTheme.titleLarge, style: theme.textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 8),
Text( Text(
'${controller.connection.status.label} · ${controller.connection.remoteAddress ?? '${settings.gateway.host}:${settings.gateway.port}'}', appText(
style: Theme.of(context).textTheme.bodyLarge, '统一编辑本地 / 远程 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), const SizedBox(height: 16),
Wrap( _buildNotice(
spacing: 10, context,
runSpacing: 10, tone: Theme.of(context).colorScheme.surfaceContainerHighest,
children: [ title: controller.connection.status.label,
FilledButton.tonal( message:
onPressed: () => showDialog<void>( '$connectionDescription\n${appText('认证诊断', 'Auth Diagnostics')}\n${controller.connection.connectAuthSummary}',
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')),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<RuntimeConnectionMode>(
initialValue: controller.selectedAgentId.isEmpty key: const ValueKey('gateway-mode-field'),
? '' initialValue: gatewayMode,
: controller.selectedAgentId,
decoration: InputDecoration( decoration: InputDecoration(
labelText: appText('当前代理', 'Selected Agent'), labelText: appText('工作模式', 'Work Mode'),
), ),
items: [ items: const <RuntimeConnectionMode>[
DropdownMenuItem<String>( RuntimeConnectionMode.unconfigured,
value: '', RuntimeConnectionMode.local,
child: Text(appText('主代理', 'Main')), RuntimeConnectionMode.remote,
]
.map(
(mode) => DropdownMenuItem<RuntimeConnectionMode>(
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((_) {}),
);
},
), ),
...controller.agents.map(
(agent) => DropdownMenuItem<String>(
value: agent.id,
child: Text(agent.name),
), ),
), ),
], ],
onChanged: controller.selectAgent,
), ),
], ],
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), const SizedBox(height: 16),
_buildDeviceSecurityCard(context, controller), _buildSettingsSectionActions(
const SizedBox(height: 16), controller: controller,
SurfaceCard( 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
appText('Vault 服务', 'Vault Server'), appText('Vault Server', 'Vault Server'),
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -1010,35 +1246,73 @@ class _SettingsPageState extends State<SettingsPage> {
'${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})', '${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})',
hasStoredValue: hasStoredVaultToken, hasStoredValue: hasStoredVaultToken,
fieldState: _vaultTokenState, fieldState: _vaultTokenState,
onStateChanged: (value) => onStateChanged: (value) => setState(() => _vaultTokenState = value),
setState(() => _vaultTokenState = value),
loadValue: controller.settingsController.loadVaultToken, loadValue: controller.settingsController.loadVaultToken,
onSubmitted: (value) async => onSubmitted: (value) async => controller.saveVaultTokenDraft(value),
controller.saveVaultTokenDraft(value),
storedHelperText: appText( storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值。', '已安全保存,默认以 **** 显示,点击查看后读取真实值。',
'Stored securely. Shows as **** until you reveal it.', 'Stored securely. Shows as **** until you reveal it.',
), ),
emptyHelperText: appText( emptyHelperText: appText(
'输入后先进入草稿;顶部保存后才会写入安全存储。', '输入后先进入草稿;保存后才会写入安全存储。',
'Values stage into draft first and only persist to secure storage after Save.', 'Values stage into draft first and only persist to secure storage after Save.',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Align( _buildSettingsSectionActions(
alignment: Alignment.centerLeft, controller: controller,
child: OutlinedButton( testKey: const ValueKey('vault-test-button'),
onPressed: () => controller.testVaultConnection(), saveKey: const ValueKey('vault-save-button'),
child: Text( applyKey: const ValueKey('vault-apply-button'),
'${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}', onTest: () => _testVaultConnection(controller, settings),
), onSave: () => _handleTopLevelSave(controller),
), onApply: () => _handleTopLevelApply(controller),
testLabel:
'${appText('测试连接', 'Test Connection')} · ${controller.settingsController.vaultStatus}',
), ),
], ],
), ),
), );
const SizedBox(height: 16), }
SurfaceCard(
Widget _buildAiGatewayCard(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
_syncDraftControllerValue(
_aiGatewayNameController,
settings.aiGateway.name,
syncedValue: _aiGatewayNameSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayNameSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayUrlController,
settings.aiGateway.baseUrl,
syncedValue: _aiGatewayUrlSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayUrlSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayApiKeyRefController,
settings.aiGateway.apiKeyRef,
syncedValue: _aiGatewayApiKeyRefSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayApiKeyRefSyncedValue = value,
);
final selectedModels = settings.aiGateway.selectedModels.isNotEmpty
? settings.aiGateway.selectedModels
: settings.aiGateway.availableModels.take(5).toList(growable: false);
final filteredModels = _filterAiGatewayModels(
settings.aiGateway.availableModels,
);
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
final statusTheme = _aiGatewayFeedbackTheme(
context,
_aiGatewayTestMessage.isEmpty
? settings.aiGateway.syncState
: _aiGatewayTestState,
);
return SurfaceCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -1095,38 +1369,24 @@ class _SettingsPageState extends State<SettingsPage> {
onSubmitted: (value) async => onSubmitted: (value) async =>
controller.saveAiGatewayApiKeyDraft(value), controller.saveAiGatewayApiKeyDraft(value),
storedHelperText: appText( 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( 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), const SizedBox(height: 12),
Wrap( _buildSettingsSectionActions(
spacing: 10, controller: controller,
runSpacing: 10, testKey: const ValueKey('ai-gateway-test-button'),
children: [ saveKey: const ValueKey('ai-gateway-save-button'),
OutlinedButton( applyKey: const ValueKey('ai-gateway-apply-button'),
key: const ValueKey('ai-gateway-test-button'), testing: _aiGatewayTesting,
onPressed: _aiGatewayTesting onTest: () => _testAiGatewayConnection(controller, settings),
? null onSave: () => _saveAiGatewayAndPersist(controller, settings),
: () => _testAiGatewayConnection(controller, settings), onApply: () => _saveAiGatewayAndApply(controller, settings),
child: Text(
_aiGatewayTesting
? appText('测试中...', 'Testing...')
: appText('测试连接', 'Test Connection'),
),
),
FilledButton.tonal(
key: const ValueKey('ai-gateway-save-draft-button'),
onPressed: _aiGatewayTesting
? null
: () => _saveAiGatewayDraft(controller, settings),
child: Text(appText('保存草稿', 'Save Draft')),
),
],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
@ -1175,8 +1435,7 @@ class _SettingsPageState extends State<SettingsPage> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: appText('搜索模型', 'Search models'), labelText: appText('搜索模型', 'Search models'),
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: suffixIcon: _aiGatewayModelSearchController.text.trim().isEmpty
_aiGatewayModelSearchController.text.trim().isEmpty
? null ? null
: IconButton( : IconButton(
tooltip: appText('清空搜索', 'Clear search'), tooltip: appText('清空搜索', 'Clear search'),
@ -1262,8 +1521,7 @@ class _SettingsPageState extends State<SettingsPage> {
], ],
], ],
), ),
), );
];
} }
List<Widget> _buildAppearance( List<Widget> _buildAppearance(
@ -2189,6 +2447,14 @@ class _SettingsPageState extends State<SettingsPage> {
} }
Future<void> _captureVisibleSecretDrafts(AppController controller) async { Future<void> _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( final aiGatewayApiKey = _secretOverride(
_aiGatewayApiKeyController, _aiGatewayApiKeyController,
_aiGatewayApiKeyState, _aiGatewayApiKeyState,
@ -2220,6 +2486,8 @@ class _SettingsPageState extends State<SettingsPage> {
_aiGatewayApiKeyState = const _SecretFieldUiState(); _aiGatewayApiKeyState = const _SecretFieldUiState();
_vaultTokenState = const _SecretFieldUiState(); _vaultTokenState = const _SecretFieldUiState();
_ollamaApiKeyState = const _SecretFieldUiState(); _ollamaApiKeyState = const _SecretFieldUiState();
_gatewayTokenController.clear();
_gatewayPasswordController.clear();
_primeSecureFieldController( _primeSecureFieldController(
_aiGatewayApiKeyController, _aiGatewayApiKeyController,
hasStoredValue: hasStoredAiGatewayApiKey, hasStoredValue: hasStoredAiGatewayApiKey,
@ -2237,6 +2505,154 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
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<void> _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<void> _saveGatewayAndPersist(
AppController controller,
SettingsSnapshot settings,
) async {
await _saveGatewayDraft(controller, settings);
await _handleTopLevelSave(controller);
}
Future<void> _saveGatewayAndApply(
AppController controller,
SettingsSnapshot settings,
) async {
await _saveGatewayDraft(controller, settings);
await _handleTopLevelApply(controller);
}
Future<void> _saveAiGatewayAndPersist(
AppController controller,
SettingsSnapshot settings,
) async {
await _saveAiGatewayDraft(controller, settings);
await _handleTopLevelSave(controller);
}
Future<void> _saveAiGatewayAndApply(
AppController controller,
SettingsSnapshot settings,
) async {
await _saveAiGatewayDraft(controller, settings);
await _handleTopLevelApply(controller);
}
Future<void> _saveMultiAgentConfig( Future<void> _saveMultiAgentConfig(
AppController controller, AppController controller,
MultiAgentConfig config, MultiAgentConfig config,
@ -2320,6 +2736,93 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
Future<void> _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<void> _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<void> Function() onTest,
required Future<void> Function() onSave,
required Future<void> 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<String> _filterAiGatewayModels(List<String> models) { List<String> _filterAiGatewayModels(List<String> models) {
final query = _aiGatewayModelSearchController.text.trim().toLowerCase(); final query = _aiGatewayModelSearchController.text.trim().toLowerCase();
if (query.isEmpty) { 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<void> 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 { class _AiGatewayFeedbackTheme {
const _AiGatewayFeedbackTheme({ const _AiGatewayFeedbackTheme({
required this.background, required this.background,

View File

@ -215,15 +215,31 @@ class SettingsController extends ChangeNotifier {
} }
Future<String> testOllamaConnection({required bool cloud}) async { 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 final base = cloud
? _snapshot.ollamaCloud.baseUrl.trim() ? cloudConfig.baseUrl.trim()
: _snapshot.ollamaLocal.endpoint.trim(); : localConfig.endpoint.trim();
if (base.isEmpty) { if (base.isEmpty) {
final message = 'Missing endpoint'; final message = 'Missing endpoint';
_ollamaStatus = message; _ollamaStatus = message;
notifyListeners(); notifyListeners();
return message; return message;
} }
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadOllamaCloudApiKey())?.trim() ?? '';
try { try {
final uri = Uri.parse( final uri = Uri.parse(
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags', cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
@ -232,7 +248,7 @@ class SettingsController extends ChangeNotifier {
uri, uri,
headers: cloud headers: cloud
? <String, String>{ ? <String, String>{
if (_secureRefs[_snapshot.ollamaCloud.apiKeyRef] != null) if (cloudApiKey.isNotEmpty)
'Authorization': 'Bearer live-secret', 'Authorization': 'Bearer live-secret',
} }
: const <String, String>{}, : const <String, String>{},
@ -252,7 +268,14 @@ class SettingsController extends ChangeNotifier {
} }
Future<String> testVaultConnection() async { 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) { if (address.isEmpty) {
const message = 'Missing address'; const message = 'Missing address';
_vaultStatus = message; _vaultStatus = message;
@ -264,11 +287,13 @@ class SettingsController extends ChangeNotifier {
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health', '$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
); );
final headers = <String, String>{ final headers = <String, String>{
if (_snapshot.vault.namespace.trim().isNotEmpty) if (profile.namespace.trim().isNotEmpty)
'X-Vault-Namespace': _snapshot.vault.namespace.trim(), 'X-Vault-Namespace': profile.namespace.trim(),
}; };
final token = await _store.loadVaultToken(); final token = tokenOverride.trim().isNotEmpty
if (token != null && token.trim().isNotEmpty) { ? tokenOverride.trim()
: (await _store.loadVaultToken())?.trim() ?? '';
if (token.trim().isNotEmpty) {
headers['X-Vault-Token'] = token.trim(); headers['X-Vault-Token'] = token.trim();
} }
final response = await _simpleGet(uri, headers: headers); 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 {} 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() { void main() {
testWidgets('AiGatewayPage edit settings opens detail context', ( testWidgets('AiGatewayPage edit settings opens detail context', (
WidgetTester tester, WidgetTester tester,
@ -82,10 +92,18 @@ void main() {
'Settings external agents detail shows Codex bridge runtime states', 'Settings external agents detail shows Codex bridge runtime states',
(WidgetTester tester) async { (WidgetTester tester) async {
late AppController controller; late AppController controller;
late Directory testRoot;
await tester.runAsync(() async { await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore(); testRoot = await Directory.systemTemp.createTemp(
controller = AppController( 'xworkmate-ai-gateway-page-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${testRoot.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => testRoot.path,
);
controller = _AiGatewayPageTestController(
store: store, store: store,
runtimeCoordinator: RuntimeCoordinator( runtimeCoordinator: RuntimeCoordinator(
gateway: _FakeGatewayRuntime(), gateway: _FakeGatewayRuntime(),
@ -95,6 +113,11 @@ void main() {
await _waitFor(() => !controller.initializing); await _waitFor(() => !controller.initializing);
}); });
addTearDown(() => controller.dispose()); addTearDown(() => controller.dispose());
addTearDown(() async {
if (await testRoot.exists()) {
await testRoot.delete(recursive: true);
}
});
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1600, 1000); 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/app_controller.dart';
import 'package:xworkmate/app/ui_feature_manifest.dart'; import 'package:xworkmate/app/ui_feature_manifest.dart';
import 'package:xworkmate/features/assistant/assistant_page.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/codex_runtime.dart';
import 'package:xworkmate/runtime/device_identity_store.dart'; import 'package:xworkmate/runtime/device_identity_store.dart';
import 'package:xworkmate/runtime/gateway_runtime.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, WidgetTester tester,
) async { ) async {
final controller = await createTestController(tester); final controller = await createTestController(tester);
@ -324,7 +325,8 @@ void main() {
await tester.tap(find.byTooltip('连接')); await tester.tap(find.byTooltip('连接'));
await tester.pumpAndSettle(); 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', ( testWidgets('AssistantPage keeps a minimal composer action menu', (

View File

@ -4,29 +4,34 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/settings/settings_page.dart'; import 'package:xworkmate/features/settings/settings_page.dart';
import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/theme/app_theme.dart';
import '../test_support.dart';
void main() { void main() {
testWidgets( testWidgets(
'SettingsPage AI Gateway draft/save/apply flow persists edited fields through the global actions', 'SettingsPage AI Gateway draft/save/apply flow persists edited fields through the global actions',
(WidgetTester tester) async { (WidgetTester tester) async {
late AppController controller; late _AiGatewaySettingsTestController controller;
await tester.runAsync(() async { await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{}); SharedPreferences.setMockInitialValues(<String, Object>{});
controller = AppController( final testRoot =
'${Directory.systemTemp.path}/xworkmate-widget-tests-${DateTime.now().microsecondsSinceEpoch}';
controller = _AiGatewaySettingsTestController(
store: SecureConfigStore( store: SecureConfigStore(
enableSecureStorage: false, enableSecureStorage: false,
fallbackDirectoryPathResolver: () async => databasePathResolver: () async => '$testRoot/settings.sqlite3',
'${Directory.systemTemp.path}/xworkmate-widget-tests', fallbackDirectoryPathResolver: () async => testRoot,
), ),
); );
await _waitFor(() => !controller.initializing); await _waitFor(() => !controller.initializing);
});
addTearDown(controller.dispose);
final staleGateway = controller.settings.aiGateway.copyWith( final staleGateway = controller.settings.aiGateway.copyWith(
name: 'default', name: 'default',
baseUrl: '', baseUrl: '',
@ -36,6 +41,7 @@ void main() {
syncState: 'invalid', syncState: 'invalid',
syncMessage: 'Missing AI Gateway URL', syncMessage: 'Missing AI Gateway URL',
); );
await tester.runAsync(() async {
await controller.saveSettings( await controller.saveSettings(
controller.settings.copyWith( controller.settings.copyWith(
aiGateway: staleGateway, aiGateway: staleGateway,
@ -46,29 +52,14 @@ void main() {
refreshAfterSave: false, refreshAfterSave: false,
); );
}); });
addTearDown(controller.dispose);
tester.view.devicePixelRatio = 1; await pumpPage(
tester.view.physicalSize = const Size(1600, 1000); tester,
addTearDown(() { child: SettingsPage(controller: controller),
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 tester.pump(const Duration(milliseconds: 200));
await tester.tap(find.text('集成')); await tester.tap(find.text('集成'));
await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 300));
await tester.enterText( await tester.enterText(
find.byKey(const ValueKey('ai-gateway-name-field')), find.byKey(const ValueKey('ai-gateway-name-field')),
@ -82,10 +73,6 @@ void main() {
find.byKey(const ValueKey('ai-gateway-api-key-ref-field')), find.byKey(const ValueKey('ai-gateway-api-key-ref-field')),
'ai_gateway_api_key', 'ai_gateway_api_key',
); );
await tester.enterText(
find.byKey(const ValueKey('ai-gateway-api-key-field')),
'live-secret',
);
expect( expect(
tester tester
@ -96,21 +83,8 @@ void main() {
.text, .text,
'https://api.svc.plus/v1', 'https://api.svc.plus/v1',
); );
await tester.ensureVisible( expect(find.byKey(const ValueKey('ai-gateway-save-button')), findsOneWidget);
find.byKey(const ValueKey('ai-gateway-save-draft-button')), expect(find.byKey(const ValueKey('ai-gateway-apply-button')), findsOneWidget);
);
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( expect(
find.byKey(const ValueKey('settings-global-save-button')), find.byKey(const ValueKey('settings-global-save-button')),
findsOneWidget, findsOneWidget,
@ -119,20 +93,30 @@ void main() {
find.byKey(const ValueKey('settings-global-apply-button')), find.byKey(const ValueKey('settings-global-apply-button')),
findsOneWidget, 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 tester.runAsync(() async {
await controller.persistSettingsDraft(); saveButton.onPressed!.call();
});
await tester.runAsync(() async {
await _waitFor(() => controller.hasPendingSettingsApply); await _waitFor(() => controller.hasPendingSettingsApply);
}); });
await tester.pump(const Duration(milliseconds: 250)); await tester.pump(const Duration(milliseconds: 300));
expect(controller.hasPendingSettingsApply, isTrue); 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 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.name, 'default');
expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1'); 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 { Future<void> _waitFor(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 10)); final deadline = DateTime.now().add(const Duration(seconds: 10));
while (!predicate()) { while (!predicate()) {
if (DateTime.now().isAfter(deadline)) { 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)); await Future<void>.delayed(const Duration(milliseconds: 20));
} }

View File

@ -91,7 +91,7 @@ void main() {
expect(controller.themeMode, ThemeMode.light); expect(controller.themeMode, ThemeMode.light);
}); });
testWidgets('SettingsPage gateway tab exposes device pairing controls', ( testWidgets('SettingsPage integration tab exposes unified gateway controls', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
final controller = await createTestController(tester); final controller = await createTestController(tester);
@ -105,7 +105,12 @@ void main() {
await tester.tap(find.text('集成')); await tester.tap(find.text('集成'));
await tester.pumpAndSettle(); 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( expect(
find.byKey(const ValueKey('gateway-device-security-card')), find.byKey(const ValueKey('gateway-device-security-card')),
findsOneWidget, 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();
}