Integrate gateway settings into integrations page
This commit is contained in:
parent
ee3f9ec80b
commit
93032366bd
1
dart_test.yaml
Normal file
1
dart_test.yaml
Normal file
@ -0,0 +1 @@
|
||||
concurrency: 1
|
||||
@ -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;
|
||||
}
|
||||
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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,20 +146,14 @@ 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'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showMobileSafeSheet() {
|
||||
@ -689,7 +682,7 @@ class _MobileSafeSheet extends StatelessWidget {
|
||||
child: Text(
|
||||
controller.canQuickConnectGateway
|
||||
? appText('快速连接', 'Quick Connect')
|
||||
: appText('打开连接面板', 'Open Connection'),
|
||||
: appText('打开集成设置', 'Open Integrations'),
|
||||
),
|
||||
),
|
||||
if (hasPendingRun)
|
||||
|
||||
@ -9,9 +9,9 @@ import '../../app/workspace_navigation.dart';
|
||||
import '../ai_gateway/ai_gateway_page.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
import '../../runtime/gateway_runtime.dart';
|
||||
import '../../runtime/runtime_controllers.dart';
|
||||
import '../../runtime/runtime_models.dart';
|
||||
import '../../widgets/gateway_connect_dialog.dart';
|
||||
import '../../widgets/section_tabs.dart';
|
||||
import '../../widgets/surface_card.dart';
|
||||
import '../../widgets/top_bar.dart';
|
||||
@ -45,9 +45,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController _aiGatewayApiKeyRefController;
|
||||
late final TextEditingController _aiGatewayApiKeyController;
|
||||
late final TextEditingController _aiGatewayModelSearchController;
|
||||
late final TextEditingController _gatewaySetupCodeController;
|
||||
late final TextEditingController _gatewayHostController;
|
||||
late final TextEditingController _gatewayPortController;
|
||||
late final TextEditingController _gatewayTokenController;
|
||||
late final TextEditingController _gatewayPasswordController;
|
||||
late final TextEditingController _vaultTokenController;
|
||||
late final TextEditingController _ollamaApiKeyController;
|
||||
late final TextEditingController _runtimeLogFilterController;
|
||||
bool _gatewayTesting = false;
|
||||
String _gatewayTestState = 'idle';
|
||||
String _gatewayTestMessage = '';
|
||||
String _gatewayTestEndpoint = '';
|
||||
String _gatewaySetupCodeSyncedValue = '';
|
||||
String _gatewayHostSyncedValue = '';
|
||||
String _gatewayPortSyncedValue = '';
|
||||
RuntimeConnectionMode? _gatewayDraftMode;
|
||||
bool? _gatewayDraftUseSetupCode;
|
||||
bool? _gatewayDraftTls;
|
||||
bool _aiGatewayTesting = false;
|
||||
String _aiGatewayTestState = 'idle';
|
||||
String _aiGatewayTestMessage = '';
|
||||
@ -70,6 +85,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_aiGatewayApiKeyRefController = TextEditingController();
|
||||
_aiGatewayApiKeyController = TextEditingController();
|
||||
_aiGatewayModelSearchController = TextEditingController();
|
||||
_gatewaySetupCodeController = TextEditingController();
|
||||
_gatewayHostController = TextEditingController();
|
||||
_gatewayPortController = TextEditingController();
|
||||
_gatewayTokenController = TextEditingController();
|
||||
_gatewayPasswordController = TextEditingController();
|
||||
_vaultTokenController = TextEditingController();
|
||||
_ollamaApiKeyController = TextEditingController();
|
||||
_runtimeLogFilterController = TextEditingController();
|
||||
@ -96,6 +116,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_aiGatewayApiKeyRefController.dispose();
|
||||
_aiGatewayApiKeyController.dispose();
|
||||
_aiGatewayModelSearchController.dispose();
|
||||
_gatewaySetupCodeController.dispose();
|
||||
_gatewayHostController.dispose();
|
||||
_gatewayPortController.dispose();
|
||||
_gatewayTokenController.dispose();
|
||||
_gatewayPasswordController.dispose();
|
||||
_vaultTokenController.dispose();
|
||||
_ollamaApiKeyController.dispose();
|
||||
_runtimeLogFilterController.dispose();
|
||||
@ -224,7 +249,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
SettingsSnapshot settings,
|
||||
SettingsDetailPage detail,
|
||||
) {
|
||||
final gatewaySections = _buildGateway(context, controller, settings);
|
||||
final workspaceSections = _buildWorkspace(context, controller, settings);
|
||||
return switch (detail) {
|
||||
SettingsDetailPage.gatewayConnection => <Widget>[
|
||||
@ -237,7 +261,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
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>[
|
||||
_buildDetailIntro(
|
||||
@ -249,7 +277,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (gatewaySections.isNotEmpty) gatewaySections.last,
|
||||
_buildAiGatewayCard(context, controller, settings),
|
||||
],
|
||||
SettingsDetailPage.vaultProvider => <Widget>[
|
||||
_buildDetailIntro(
|
||||
@ -261,7 +289,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (gatewaySections.length > 4) gatewaySections[4],
|
||||
_buildVaultProviderCard(context, controller, settings),
|
||||
],
|
||||
SettingsDetailPage.ollamaProvider => <Widget>[
|
||||
_buildDetailIntro(
|
||||
@ -858,109 +886,317 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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 hasStoredVaultToken =
|
||||
controller.settingsController.secureRefs['vault_token'] != null;
|
||||
final statusTheme = _aiGatewayFeedbackTheme(
|
||||
context,
|
||||
_aiGatewayTestMessage.isEmpty
|
||||
? settings.aiGateway.syncState
|
||||
: _aiGatewayTestState,
|
||||
);
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'OpenClaw Gateway',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${controller.connection.status.label} · ${controller.connection.remoteAddress ?? '${settings.gateway.host}:${settings.gateway.port}'}',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
appText(
|
||||
'统一编辑本地 / 远程 OpenClaw Gateway 的连接参数。保存只持久化,应用才会按当前模式发起连接或切换为仅 AI Gateway。',
|
||||
'Edit local and remote OpenClaw gateway settings in one place. Save persists only; Apply connects or switches to AI Gateway-only mode.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
onPressed: () => showDialog<void>(
|
||||
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')),
|
||||
),
|
||||
],
|
||||
_buildNotice(
|
||||
context,
|
||||
tone: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
title: controller.connection.status.label,
|
||||
message:
|
||||
'$connectionDescription\n${appText('认证诊断', 'Auth Diagnostics')}\n${controller.connection.connectAuthSummary}',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: controller.selectedAgentId.isEmpty
|
||||
? ''
|
||||
: controller.selectedAgentId,
|
||||
DropdownButtonFormField<RuntimeConnectionMode>(
|
||||
key: const ValueKey('gateway-mode-field'),
|
||||
initialValue: gatewayMode,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('当前代理', 'Selected Agent'),
|
||||
labelText: appText('工作模式', 'Work Mode'),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: '',
|
||||
child: Text(appText('主代理', 'Main')),
|
||||
items: const <RuntimeConnectionMode>[
|
||||
RuntimeConnectionMode.unconfigured,
|
||||
RuntimeConnectionMode.local,
|
||||
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),
|
||||
_buildDeviceSecurityCard(context, controller),
|
||||
const SizedBox(height: 16),
|
||||
SurfaceCard(
|
||||
_buildSettingsSectionActions(
|
||||
controller: controller,
|
||||
testKey: const ValueKey('gateway-test-button'),
|
||||
saveKey: const ValueKey('gateway-save-button'),
|
||||
applyKey: const ValueKey('gateway-apply-button'),
|
||||
testing: _gatewayTesting,
|
||||
onTest: () => _testGatewayConnection(
|
||||
controller,
|
||||
settings,
|
||||
executionTarget: gatewayTarget,
|
||||
),
|
||||
onSave: () => _saveGatewayAndPersist(controller, settings),
|
||||
onApply: () => _saveGatewayAndApply(controller, settings),
|
||||
),
|
||||
if (_gatewayTestMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildNotice(
|
||||
context,
|
||||
tone: _gatewayTestState == 'success'
|
||||
? Theme.of(context).colorScheme.secondaryContainer
|
||||
: Theme.of(context).colorScheme.errorContainer,
|
||||
title: appText('测试连接', 'Test Connection'),
|
||||
message: _gatewayTestEndpoint.isEmpty
|
||||
? _gatewayTestMessage
|
||||
: '$_gatewayTestMessage\n$_gatewayTestEndpoint',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVaultProviderCard(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
final hasStoredVaultToken =
|
||||
controller.settingsController.secureRefs['vault_token'] != null;
|
||||
return SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('Vault 服务', 'Vault Server'),
|
||||
appText('Vault Server', 'Vault Server'),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@ -1010,35 +1246,73 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
'${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})',
|
||||
hasStoredValue: hasStoredVaultToken,
|
||||
fieldState: _vaultTokenState,
|
||||
onStateChanged: (value) =>
|
||||
setState(() => _vaultTokenState = value),
|
||||
onStateChanged: (value) => setState(() => _vaultTokenState = value),
|
||||
loadValue: controller.settingsController.loadVaultToken,
|
||||
onSubmitted: (value) async =>
|
||||
controller.saveVaultTokenDraft(value),
|
||||
onSubmitted: (value) async => controller.saveVaultTokenDraft(value),
|
||||
storedHelperText: appText(
|
||||
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
|
||||
'Stored securely. Shows as **** until you reveal it.',
|
||||
),
|
||||
emptyHelperText: appText(
|
||||
'输入后先进入草稿;顶部保存后才会写入安全存储。',
|
||||
'输入后先进入草稿;保存后才会写入安全存储。',
|
||||
'Values stage into draft first and only persist to secure storage after Save.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => controller.testVaultConnection(),
|
||||
child: Text(
|
||||
'${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}',
|
||||
),
|
||||
),
|
||||
_buildSettingsSectionActions(
|
||||
controller: controller,
|
||||
testKey: const ValueKey('vault-test-button'),
|
||||
saveKey: const ValueKey('vault-save-button'),
|
||||
applyKey: const ValueKey('vault-apply-button'),
|
||||
onTest: () => _testVaultConnection(controller, settings),
|
||||
onSave: () => _handleTopLevelSave(controller),
|
||||
onApply: () => _handleTopLevelApply(controller),
|
||||
testLabel:
|
||||
'${appText('测试连接', 'Test Connection')} · ${controller.settingsController.vaultStatus}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -1095,38 +1369,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onSubmitted: (value) async =>
|
||||
controller.saveAiGatewayApiKeyDraft(value),
|
||||
storedHelperText: appText(
|
||||
'已安全保存,默认以 **** 显示;可直接测试,也可保存草稿后再统一提交。',
|
||||
'Stored securely. Test directly or save to draft before the global submit.',
|
||||
'已安全保存,默认以 **** 显示;可直接测试,也可通过本区保存/应用提交。',
|
||||
'Stored securely. Test directly or submit it with the local Save / Apply actions.',
|
||||
),
|
||||
emptyHelperText: appText(
|
||||
'输入后可测试连接,或先保存到草稿,顶部再统一保存/应用。',
|
||||
'Test the connection now, or stage it for the top-level Save / Apply flow.',
|
||||
'输入后可直接测试,也可通过本区或顶部按钮统一保存/应用。',
|
||||
'Test it now, or use the local or top-level Save / Apply actions.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
key: const ValueKey('ai-gateway-test-button'),
|
||||
onPressed: _aiGatewayTesting
|
||||
? null
|
||||
: () => _testAiGatewayConnection(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')),
|
||||
),
|
||||
],
|
||||
_buildSettingsSectionActions(
|
||||
controller: controller,
|
||||
testKey: const ValueKey('ai-gateway-test-button'),
|
||||
saveKey: const ValueKey('ai-gateway-save-button'),
|
||||
applyKey: const ValueKey('ai-gateway-apply-button'),
|
||||
testing: _aiGatewayTesting,
|
||||
onTest: () => _testAiGatewayConnection(controller, settings),
|
||||
onSave: () => _saveAiGatewayAndPersist(controller, settings),
|
||||
onApply: () => _saveAiGatewayAndApply(controller, settings),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@ -1175,8 +1435,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('搜索模型', 'Search models'),
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon:
|
||||
_aiGatewayModelSearchController.text.trim().isEmpty
|
||||
suffixIcon: _aiGatewayModelSearchController.text.trim().isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: appText('清空搜索', 'Clear search'),
|
||||
@ -1262,8 +1521,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildAppearance(
|
||||
@ -2189,6 +2447,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
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(
|
||||
_aiGatewayApiKeyController,
|
||||
_aiGatewayApiKeyState,
|
||||
@ -2220,6 +2486,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_aiGatewayApiKeyState = const _SecretFieldUiState();
|
||||
_vaultTokenState = const _SecretFieldUiState();
|
||||
_ollamaApiKeyState = const _SecretFieldUiState();
|
||||
_gatewayTokenController.clear();
|
||||
_gatewayPasswordController.clear();
|
||||
_primeSecureFieldController(
|
||||
_aiGatewayApiKeyController,
|
||||
hasStoredValue: hasStoredAiGatewayApiKey,
|
||||
@ -2237,6 +2505,154 @@ class _SettingsPageState extends State<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(
|
||||
AppController controller,
|
||||
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) {
|
||||
final query = _aiGatewayModelSearchController.text.trim().toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
@ -3327,6 +3830,49 @@ class _InlineSwitchField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GatewaySecretStatusCard extends StatelessWidget {
|
||||
const _GatewaySecretStatusCard({
|
||||
required this.message,
|
||||
required this.locked,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final bool locked;
|
||||
final Future<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 {
|
||||
const _AiGatewayFeedbackTheme({
|
||||
required this.background,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -4,29 +4,34 @@ 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);
|
||||
});
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final staleGateway = controller.settings.aiGateway.copyWith(
|
||||
name: 'default',
|
||||
baseUrl: '',
|
||||
@ -36,6 +41,7 @@ void main() {
|
||||
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));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import '../test_suite_stub.dart'
|
||||
if (dart.library.io) 'gateway_connect_dialog_suite.dart'
|
||||
as suite;
|
||||
|
||||
void main() {
|
||||
suite.main();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user