diff --git a/integration_test/desktop_navigation_flow_test.dart b/integration_test/desktop_navigation_flow_test.dart index d2ff9a4a..6f1076e5 100644 --- a/integration_test/desktop_navigation_flow_test.dart +++ b/integration_test/desktop_navigation_flow_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test/helpers/test_keys.dart'; diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart index 485aa395..f3f6d72d 100644 --- a/integration_test/desktop_settings_flow_test.dart +++ b/integration_test/desktop_settings_flow_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test/helpers/test_keys.dart'; diff --git a/lib/features/account/account_page.dart b/lib/features/account/account_page.dart index 1026efa3..e1ca0792 100644 --- a/lib/features/account/account_page.dart +++ b/lib/features/account/account_page.dart @@ -229,7 +229,13 @@ class _AccountPageState extends State { String profileDescription, String sessionStatusText, String syncStatusText, + AccountSyncState? accountSyncState, ) { + final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced; + final remoteSummary = cloudSync.remoteServerSummary; + final syncSummaryText = remoteSummary.endpoint.trim().isEmpty + ? appText('还没有云端 ACP Bridge Server 摘要。', 'No cloud ACP Bridge Server summary yet.') + : remoteSummary.endpoint; return SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -259,6 +265,51 @@ class _AccountPageState extends State { style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('云端 ACP Bridge Server 摘要', 'Cloud ACP Bridge Server Summary'), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text( + '${appText('服务地址', 'Service URL')}: ${cloudSync.accountBaseUrl.trim().isEmpty ? settings.accountBaseUrl : cloudSync.accountBaseUrl}', + key: const ValueKey('account-acp-sync-summary-url'), + ), + const SizedBox(height: 6), + Text( + '${appText('同步目标', 'Synced Target')}: $syncSummaryText', + key: const ValueKey('account-acp-sync-summary-endpoint'), + ), + const SizedBox(height: 6), + Text( + '${appText('最近同步', 'Last Sync')}: ${accountSyncState == null || cloudSync.lastSyncAt <= 0 ? appText('尚未同步', 'Not synced yet') : DateTime.fromMillisecondsSinceEpoch(cloudSync.lastSyncAt).toLocal().toIso8601String()}', + ), + const SizedBox(height: 10), + FilledButton.tonal( + key: const ValueKey('account-open-settings-acp'), + onPressed: () => widget.controller.openSettings( + tab: SettingsTab.gateway, + ), + child: Text( + appText( + '前往设置中的 ACP Bridge Server', + 'Open ACP Bridge Server in Settings', + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), TextFormField( key: const ValueKey('account-base-url-field'), controller: _accountBaseUrlController, @@ -364,22 +415,17 @@ class _AccountPageState extends State { : '${accountSyncState.syncState} · ${accountSyncState.syncMessage}'; final profileDescription = accountSignedIn ? appText( - '已登录,远端配置作为默认值;本地保存项优先', - 'Signed in. Remote defaults apply first, and local saved values win.', + '这里继续只负责账号身份、MFA、工作区与同步摘要。ACP Bridge Server 的三模式配置已统一收口到设置页。', + 'This page now focuses on identity, MFA, workspace, and sync summary only. ACP Bridge Server mode configuration now lives in Settings.', ) : accountMfaRequired ? appText( '请输入 MFA 验证码完成同步,也可以返回编辑账号信息。', 'Enter the MFA code to finish sync, or return to edit account details.', ) - : settings.accountLocalMode - ? appText( - '本地模式 · 仅保存工作区偏好', - 'Local mode · saves workspace preferences only', - ) : appText( - '登录后会同步远端默认配置,本地保存项可以覆盖远端默认值。', - 'Signing in syncs remote defaults, and local saved values can override them.', + '登录后会同步云端默认配置;更细粒度的 Bridge Server、自托管和高级自定义请前往设置页。', + 'Signing in syncs the cloud defaults. For bridge server self-hosting and advanced overrides, use the Settings page.', ); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), @@ -427,6 +473,7 @@ class _AccountPageState extends State { profileDescription, sessionStatusText, syncStatusText, + accountSyncState, ), if (_tab == AccountTab.workspace) SurfaceCard( diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 263f35a6..d80674b8 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -70,6 +70,9 @@ class SettingsPageStateInternal extends State { late final TextEditingController vaultTokenControllerInternal; late final TextEditingController ollamaApiKeyControllerInternal; late final TextEditingController runtimeLogFilterControllerInternal; + late final TextEditingController acpBridgeServerUrlControllerInternal; + late final TextEditingController acpBridgeServerUsernameControllerInternal; + late final TextEditingController acpBridgeServerPasswordControllerInternal; late final Map externalAcpLabelControllersInternal; late final Map @@ -102,6 +105,11 @@ class SettingsPageStateInternal extends State { String aiGatewayTestStateInternal = 'idle'; String aiGatewayTestMessageInternal = ''; String aiGatewayTestEndpointInternal = ''; + String acpBridgeServerUrlSyncedValueInternal = ''; + String acpBridgeServerUsernameSyncedValueInternal = ''; + String acpBridgeServerPasswordRefSyncedValueInternal = ''; + bool acpBridgeServerSelfHostedTestingInternal = false; + String acpBridgeServerSelfHostedMessageInternal = ''; GatewayIntegrationSubTabInternal integrationSubTabInternal = GatewayIntegrationSubTabInternal.gateway; int llmEndpointSlotLimitInternal = 1; @@ -174,6 +182,9 @@ class SettingsPageStateInternal extends State { vaultTokenControllerInternal = TextEditingController(); ollamaApiKeyControllerInternal = TextEditingController(); runtimeLogFilterControllerInternal = TextEditingController(); + acpBridgeServerUrlControllerInternal = TextEditingController(); + acpBridgeServerUsernameControllerInternal = TextEditingController(); + acpBridgeServerPasswordControllerInternal = TextEditingController(); externalAcpLabelControllersInternal = {}; externalAcpEndpointControllersInternal = {}; externalAcpAuthControllersInternal = {}; @@ -251,6 +262,9 @@ class SettingsPageStateInternal extends State { vaultTokenControllerInternal.dispose(); ollamaApiKeyControllerInternal.dispose(); runtimeLogFilterControllerInternal.dispose(); + acpBridgeServerUrlControllerInternal.dispose(); + acpBridgeServerUsernameControllerInternal.dispose(); + acpBridgeServerPasswordControllerInternal.dispose(); for (final controller in externalAcpLabelControllersInternal.values) { controller.dispose(); } diff --git a/lib/features/settings/settings_page_gateway.dart b/lib/features/settings/settings_page_gateway.dart index 2c7ca728..6eaada20 100644 --- a/lib/features/settings/settings_page_gateway.dart +++ b/lib/features/settings/settings_page_gateway.dart @@ -43,6 +43,9 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { uiFeatures, ); } + final advancedEditable = + settings.acpBridgeServerModeConfig.mode == + AcpBridgeServerMode.advancedCustom; final tabLabel = switch (integrationSubTabInternal) { GatewayIntegrationSubTabInternal.gateway => 'OpenClaw Gateway', GatewayIntegrationSubTabInternal.vault => appText( @@ -63,6 +66,8 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { ), }; return [ + buildAcpBridgeServerModeCardInternal(context, controller, settings), + const SizedBox(height: 16), SectionTabs( items: [ 'OpenClaw Gateway', @@ -88,33 +93,45 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { const SizedBox(height: 16), ...switch (integrationSubTabInternal) { GatewayIntegrationSubTabInternal.gateway => [ - buildCollapsibleGatewaySectionInternal( - context: context, - title: 'OpenClaw Gateway', - expanded: openClawGatewayExpandedInternal, - onChanged: (value) => setStateInternal(() { - openClawGatewayExpandedInternal = value; - }), - child: buildOpenClawGatewayCardInternal( - context, - controller, - settings, + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: 'OpenClaw Gateway', + expanded: openClawGatewayExpandedInternal, + onChanged: (value) => setStateInternal(() { + openClawGatewayExpandedInternal = value; + }), + child: buildOpenClawGatewayCardInternal( + context, + controller, + settings, + ), + ), ), ), ], GatewayIntegrationSubTabInternal.vault => [ if (uiFeatures.supportsVaultServer) - buildCollapsibleGatewaySectionInternal( - context: context, - title: appText('Vault Server', 'Vault Server'), - expanded: vaultServerExpandedInternal, - onChanged: (value) => setStateInternal(() { - vaultServerExpandedInternal = value; - }), - child: buildVaultProviderCardInternal( - context, - controller, - settings, + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: appText('Vault Server', 'Vault Server'), + expanded: vaultServerExpandedInternal, + onChanged: (value) => setStateInternal(() { + vaultServerExpandedInternal = value; + }), + child: buildVaultProviderCardInternal( + context, + controller, + settings, + ), + ), ), ) else @@ -129,22 +146,150 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { ), ], GatewayIntegrationSubTabInternal.llm => [ - buildCollapsibleGatewaySectionInternal( + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: appText('LLM 接入点', 'LLM Endpoints'), + expanded: aiGatewayExpandedInternal, + onChanged: (value) => setStateInternal(() { + aiGatewayExpandedInternal = value; + }), + child: buildLlmEndpointManagerInternal( + context, + controller, + settings, + ), + ), + ), + ), + ], + GatewayIntegrationSubTabInternal.acp => [ + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: appText( + '外部 ACP Server Endpoint', + 'External ACP Server Endpoints', + ), + expanded: externalAcpExpandedInternal, + onChanged: (value) => setStateInternal(() { + externalAcpExpandedInternal = value; + }), + child: buildExternalAcpEndpointManagerInternal( + context, + controller, + settings, + ), + ), + ), + ), + ], + GatewayIntegrationSubTabInternal.skills => [ + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), + expanded: skillsDirectoryAuthorizationExpandedInternal, + onChanged: (value) => setStateInternal(() { + skillsDirectoryAuthorizationExpandedInternal = value; + }), + child: SkillDirectoryAuthorizationCard( + controller: controller, + showHeader: false, + ), + ), + ), + ), + ], + }, + ]; + } + + List buildUnifiedGatewaySectionsInternal( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + UiFeatureAccess uiFeatures, + ) { + final advancedEditable = + settings.acpBridgeServerModeConfig.mode == + AcpBridgeServerMode.advancedCustom; + return [ + buildAcpBridgeServerModeCardInternal(context, controller, settings), + const SizedBox(height: 16), + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: 'OpenClaw Gateway', + expanded: openClawGatewayExpandedInternal, + onChanged: (value) => setStateInternal(() { + openClawGatewayExpandedInternal = value; + }), + child: buildOpenClawGatewayCardInternal(context, controller, settings), + ), + ), + ), + const SizedBox(height: 16), + if (uiFeatures.supportsVaultServer) + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( + context: context, + title: appText('Vault Server', 'Vault Server'), + expanded: vaultServerExpandedInternal, + onChanged: (value) => setStateInternal(() { + vaultServerExpandedInternal = value; + }), + child: buildVaultProviderCardInternal(context, controller, settings), + ), + ), + ) + else + SurfaceCard( + borderWidth: settingsHairlineBorderWidthInternal, + child: Text( + appText( + '当前发布配置未开放 Vault Server 参数。', + 'Vault Server settings are disabled in this release configuration.', + ), + ), + ), + const SizedBox(height: 16), + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( context: context, title: appText('LLM 接入点', 'LLM Endpoints'), expanded: aiGatewayExpandedInternal, onChanged: (value) => setStateInternal(() { aiGatewayExpandedInternal = value; }), - child: buildLlmEndpointManagerInternal( - context, - controller, - settings, - ), + child: buildLlmEndpointManagerInternal(context, controller, settings), ), - ], - GatewayIntegrationSubTabInternal.acp => [ - buildCollapsibleGatewaySectionInternal( + ), + ), + const SizedBox(height: 16), + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( context: context, title: appText( '外部 ACP Server Endpoint', @@ -160,9 +305,14 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { settings, ), ), - ], - GatewayIntegrationSubTabInternal.skills => [ - buildCollapsibleGatewaySectionInternal( + ), + ), + const SizedBox(height: 16), + Opacity( + opacity: advancedEditable ? 1 : 0.72, + child: IgnorePointer( + ignoring: !advancedEditable, + child: buildCollapsibleGatewaySectionInternal( context: context, title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), expanded: skillsDirectoryAuthorizationExpandedInternal, @@ -174,86 +324,6 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal { showHeader: false, ), ), - ], - }, - ]; - } - - List buildUnifiedGatewaySectionsInternal( - BuildContext context, - AppController controller, - SettingsSnapshot settings, - UiFeatureAccess uiFeatures, - ) { - return [ - buildCollapsibleGatewaySectionInternal( - context: context, - title: 'OpenClaw Gateway', - expanded: openClawGatewayExpandedInternal, - onChanged: (value) => setStateInternal(() { - openClawGatewayExpandedInternal = value; - }), - child: buildOpenClawGatewayCardInternal(context, controller, settings), - ), - const SizedBox(height: 16), - if (uiFeatures.supportsVaultServer) - buildCollapsibleGatewaySectionInternal( - context: context, - title: appText('Vault Server', 'Vault Server'), - expanded: vaultServerExpandedInternal, - onChanged: (value) => setStateInternal(() { - vaultServerExpandedInternal = value; - }), - child: buildVaultProviderCardInternal(context, controller, settings), - ) - else - SurfaceCard( - borderWidth: settingsHairlineBorderWidthInternal, - child: Text( - appText( - '当前发布配置未开放 Vault Server 参数。', - 'Vault Server settings are disabled in this release configuration.', - ), - ), - ), - const SizedBox(height: 16), - buildCollapsibleGatewaySectionInternal( - context: context, - title: appText('LLM 接入点', 'LLM Endpoints'), - expanded: aiGatewayExpandedInternal, - onChanged: (value) => setStateInternal(() { - aiGatewayExpandedInternal = value; - }), - child: buildLlmEndpointManagerInternal(context, controller, settings), - ), - const SizedBox(height: 16), - buildCollapsibleGatewaySectionInternal( - context: context, - title: appText( - '外部 ACP Server Endpoint', - 'External ACP Server Endpoints', - ), - expanded: externalAcpExpandedInternal, - onChanged: (value) => setStateInternal(() { - externalAcpExpandedInternal = value; - }), - child: buildExternalAcpEndpointManagerInternal( - context, - controller, - settings, - ), - ), - const SizedBox(height: 16), - buildCollapsibleGatewaySectionInternal( - context: context, - title: appText('SKILLS 目录授权', 'SKILLS Directory Authorization'), - expanded: skillsDirectoryAuthorizationExpandedInternal, - onChanged: (value) => setStateInternal(() { - skillsDirectoryAuthorizationExpandedInternal = value; - }), - child: SkillDirectoryAuthorizationCard( - controller: controller, - showHeader: false, ), ), ]; diff --git a/lib/features/settings/settings_page_gateway_acp.dart b/lib/features/settings/settings_page_gateway_acp.dart index 01d36f75..2356b493 100644 --- a/lib/features/settings/settings_page_gateway_acp.dart +++ b/lib/features/settings/settings_page_gateway_acp.dart @@ -1,13 +1,17 @@ // ignore_for_file: unused_import, unnecessary_import import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../i18n/app_language.dart'; import '../../runtime/gateway_acp_client.dart'; +import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; +import '../../models/app_models.dart'; +import '../../widgets/surface_card.dart'; import 'settings_page_core.dart'; import 'settings_page_support.dart'; import 'settings_page_widgets.dart'; @@ -146,6 +150,294 @@ bool shouldRetryExternalAcpTestFailure(Object error) { } extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal { + Widget buildAcpBridgeServerModeCardInternal( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + syncAcpBridgeServerModeDraftControllersInternal(settings); + final modeConfig = settings.acpBridgeServerModeConfig; + final accountController = controller.settingsController; + final accountSyncState = accountController.accountSyncState; + final accountSignedIn = accountController.accountSignedIn; + final accountBusy = accountController.accountBusy; + final cloudSync = modeConfig.cloudSynced; + final remoteSummary = cloudSync.remoteServerSummary; + final currentSource = switch (modeConfig.sourceTag) { + 'cloudSynced' => appText('云端同步', 'Cloud Sync'), + 'selfHosted' => appText('本地 Server', 'Self-hosted Server'), + _ => appText('高级覆盖', 'Advanced Override'), + }; + final syncStatus = accountSyncState?.syncState.trim().isNotEmpty == true + ? accountSyncState!.syncState + : appText('未同步', 'Not synced'); + final lastSyncLabel = cloudSync.lastSyncAt <= 0 + ? appText('尚未同步', 'Not synced yet') + : DateTime.fromMillisecondsSinceEpoch( + cloudSync.lastSyncAt, + ).toLocal().toIso8601String(); + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'XWorkmate ACP Bridge Server', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + 'XWorkmate App 继续只承担纯客户端职责:配置、会话、安全存储与连接编排。云端、自托管和高级自定义都通过这里统一收口,不在 App 内承载服务端逻辑。', + 'XWorkmate App remains a pure client: configuration, session, secure storage, and connection orchestration only. Cloud, self-hosted, and advanced custom flows are unified here without embedding server responsibilities into the app.', + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + ChoiceChip( + key: const ValueKey('acp-bridge-mode-cloud'), + label: Text(appText('在线同步配置', 'Cloud Sync')), + selected: modeConfig.mode == AcpBridgeServerMode.cloudSynced, + onSelected: (_) => saveSettingsInternal( + controller, + settings.copyWith( + accountLocalMode: false, + acpBridgeServerModeConfig: modeConfig.copyWith( + mode: AcpBridgeServerMode.cloudSynced, + ), + ), + ), + ), + ChoiceChip( + key: const ValueKey('acp-bridge-mode-self-hosted'), + label: Text(appText('本地模式', 'Self-hosted')), + selected: modeConfig.mode == AcpBridgeServerMode.selfHosted, + onSelected: (_) => saveSettingsInternal( + controller, + settings.copyWith( + accountLocalMode: true, + acpBridgeServerModeConfig: modeConfig.copyWith( + mode: AcpBridgeServerMode.selfHosted, + ), + ), + ), + ), + ChoiceChip( + key: const ValueKey('acp-bridge-mode-advanced'), + label: Text(appText('高级自定义', 'Advanced Custom')), + selected: modeConfig.mode == AcpBridgeServerMode.advancedCustom, + onSelected: (_) => saveSettingsInternal( + controller, + settings.captureAcpBridgeServerAdvancedOverrides().copyWith( + acpBridgeServerModeConfig: settings + .captureAcpBridgeServerAdvancedOverrides() + .acpBridgeServerModeConfig + .copyWith(mode: AcpBridgeServerMode.advancedCustom), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + StatusChipInternal( + label: '${appText('当前来源', 'Source')}: $currentSource', + tone: StatusChipToneInternal.ready, + ), + StatusChipInternal( + label: '${appText('同步状态', 'Sync')}: $syncStatus', + tone: accountSignedIn + ? StatusChipToneInternal.ready + : StatusChipToneInternal.idle, + ), + ], + ), + const SizedBox(height: 16), + ...switch (modeConfig.mode) { + AcpBridgeServerMode.cloudSynced => [ + Text( + accountSignedIn + ? appText( + '已登录云账户,可直接同步远端 ACP Bridge Server 配置。', + 'Signed in to the cloud account. You can sync the remote ACP Bridge Server configuration directly.', + ) + : appText( + '当前未登录云账户。普通用户建议先登录,再从云端同步默认配置。', + 'No cloud account is signed in. For most users, sign in first and sync the default configuration from the cloud.', + ), + ), + const SizedBox(height: 12), + Text( + '${appText('服务地址', 'Service URL')}: ${cloudSync.accountBaseUrl.trim().isEmpty ? settings.accountBaseUrl : cloudSync.accountBaseUrl}', + ), + const SizedBox(height: 6), + Text( + '${appText('账号', 'Account')}: ${cloudSync.accountIdentifier.trim().isEmpty ? settings.accountUsername : cloudSync.accountIdentifier}', + ), + const SizedBox(height: 6), + Text( + '${appText('远端摘要', 'Remote Summary')}: ${remoteSummary.endpoint.trim().isEmpty ? appText('待同步', 'Pending sync') : remoteSummary.endpoint}', + ), + const SizedBox(height: 6), + Text( + '${appText('最近同步', 'Last Sync')}: $lastSyncLabel', + key: const ValueKey('acp-bridge-cloud-last-sync'), + ), + const SizedBox(height: 6), + Text( + '${appText('高级覆盖', 'Advanced Override')}: ${remoteSummary.hasAdvancedOverrides ? appText('存在', 'Present') : appText('无', 'None')}', + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.tonal( + key: const ValueKey('acp-bridge-cloud-open-account'), + onPressed: () => + controller.navigateTo(WorkspaceDestination.account), + child: Text(appText('登录 / 管理账号', 'Open Account')), + ), + FilledButton.tonal( + key: const ValueKey('acp-bridge-cloud-sync'), + onPressed: accountBusy || !accountSignedIn + ? null + : () => accountController.syncAccountSettings( + baseUrl: settings.accountBaseUrl, + ), + child: Text(appText('重新同步', 'Sync Again')), + ), + FilledButton.tonal( + key: const ValueKey('acp-bridge-cloud-disconnect'), + onPressed: accountBusy || !accountSignedIn + ? null + : accountController.logoutAccount, + child: Text(appText('断开', 'Disconnect')), + ), + ], + ), + ], + AcpBridgeServerMode.selfHosted => [ + buildAcpBridgeServerSelfHostedPanelInternal( + context, + controller, + settings, + ), + ], + AcpBridgeServerMode.advancedCustom => [ + Text( + appText( + '高级自定义会把下面的 OpenClaw Gateway / Vault Server / LLM Endpoint / 外部 ACP Server endpoint / SKILLS 目录 当作覆盖层。未覆盖的值继续继承当前基础模式。', + 'Advanced custom mode treats the OpenClaw Gateway / Vault Server / LLM Endpoint / external ACP server endpoint / SKILLS directory below as overrides. Fields you do not override keep inheriting from the current base mode.', + ), + ), + const SizedBox(height: 12), + FilledButton.tonal( + key: const ValueKey('acp-bridge-advanced-reset'), + onPressed: () => resetAcpBridgeServerAdvancedOverridesInternal( + controller, + settings, + ), + child: Text(appText('清空高级覆盖', 'Clear Advanced Overrides')), + ), + ], + }, + ], + ), + ); + } + + Widget buildAcpBridgeServerSelfHostedPanelInternal( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + final selfHosted = settings.acpBridgeServerModeConfig.selfHosted; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const ValueKey('acp-bridge-self-hosted-url'), + controller: acpBridgeServerUrlControllerInternal, + decoration: InputDecoration( + labelText: appText( + 'ACP Bridge Server URL', + 'ACP Bridge Server URL', + ), + ), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('acp-bridge-self-hosted-username'), + controller: acpBridgeServerUsernameControllerInternal, + decoration: InputDecoration( + labelText: appText('用户', 'Username'), + ), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('acp-bridge-self-hosted-password'), + controller: acpBridgeServerPasswordControllerInternal, + obscureText: true, + decoration: InputDecoration( + labelText: appText('密码', 'Password'), + helperText: appText( + '密码只进入平台 secure storage,不写入普通 settings。', + 'The password is stored only in platform secure storage and never in plain settings.', + ), + ), + ), + const SizedBox(height: 8), + Text( + '${appText('密码引用', 'Password Ref')}: ${selfHosted.passwordRef}', + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton( + key: const ValueKey('acp-bridge-self-hosted-test'), + onPressed: acpBridgeServerSelfHostedTestingInternal + ? null + : () => testAcpBridgeServerSelfHostedInternal(controller), + child: Text( + acpBridgeServerSelfHostedTestingInternal + ? appText('测试中...', 'Testing...') + : appText('测试连接', 'Test Connection'), + ), + ), + FilledButton.tonal( + key: const ValueKey('acp-bridge-self-hosted-save'), + onPressed: () => saveAcpBridgeServerSelfHostedInternal( + controller, + settings, + ), + child: Text(appText('保存', 'Save')), + ), + FilledButton( + key: const ValueKey('acp-bridge-self-hosted-connect'), + onPressed: () => connectAcpBridgeServerSelfHostedInternal( + controller, + settings, + ), + child: Text(appText('连接', 'Connect')), + ), + ], + ), + if (acpBridgeServerSelfHostedMessageInternal.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text(acpBridgeServerSelfHostedMessageInternal), + ], + ], + ); + } + Widget buildExternalAcpEndpointManagerInternal( BuildContext context, AppController controller, @@ -194,6 +486,152 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal { ); } + Future saveAcpBridgeServerSelfHostedInternal( + AppController controller, + SettingsSnapshot settings, + ) async { + final modeConfig = settings.acpBridgeServerModeConfig; + final nextSelfHosted = modeConfig.selfHosted.copyWith( + serverUrl: acpBridgeServerUrlControllerInternal.text, + username: acpBridgeServerUsernameControllerInternal.text, + ); + final password = acpBridgeServerPasswordControllerInternal.text.trim(); + if (password.isNotEmpty) { + await controller.settingsController.saveSecretValueByRef( + nextSelfHosted.passwordRef, + password, + provider: 'ACP Bridge Server', + module: 'Settings', + ); + } + final nextSettings = settings.captureAcpBridgeServerAdvancedOverrides().copyWith( + accountLocalMode: true, + acpBridgeServerModeConfig: settings + .captureAcpBridgeServerAdvancedOverrides() + .acpBridgeServerModeConfig + .copyWith( + mode: AcpBridgeServerMode.selfHosted, + selfHosted: nextSelfHosted, + ), + ); + await saveSettingsInternal(controller, nextSettings); + if (!mounted) { + return; + } + acpBridgeServerPasswordControllerInternal.clear(); + setStateInternal(() { + acpBridgeServerSelfHostedMessageInternal = appText( + 'Self-hosted 配置已保存,密码已进入 secure storage。', + 'The self-hosted configuration was saved and the password is now in secure storage.', + ); + }); + } + + Future connectAcpBridgeServerSelfHostedInternal( + AppController controller, + SettingsSnapshot settings, + ) async { + await saveAcpBridgeServerSelfHostedInternal(controller, settings); + if (!mounted) { + return; + } + await testAcpBridgeServerSelfHostedInternal(controller); + } + + Future testAcpBridgeServerSelfHostedInternal( + AppController controller, + ) async { + final endpointText = acpBridgeServerUrlControllerInternal.text.trim(); + final username = acpBridgeServerUsernameControllerInternal.text.trim(); + if (endpointText.isEmpty || username.isEmpty) { + setStateInternal(() { + acpBridgeServerSelfHostedMessageInternal = appText( + '请先填写 URL 和用户。', + 'Enter the URL and username first.', + ); + }); + return; + } + final endpoint = Uri.tryParse(endpointText); + if (endpoint == null || endpoint.host.trim().isEmpty) { + setStateInternal(() { + acpBridgeServerSelfHostedMessageInternal = appText( + '请输入有效的 ACP Bridge Server URL。', + 'Enter a valid ACP Bridge Server URL.', + ); + }); + return; + } + final password = + acpBridgeServerPasswordControllerInternal.text.trim().isNotEmpty + ? acpBridgeServerPasswordControllerInternal.text.trim() + : await controller.settingsController.loadSecretValueByRef( + controller.settings.acpBridgeServerModeConfig.selfHosted.passwordRef, + ); + final authorization = password.isEmpty + ? '' + : 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + setStateInternal(() { + acpBridgeServerSelfHostedTestingInternal = true; + acpBridgeServerSelfHostedMessageInternal = ''; + }); + try { + final capabilities = await controller.gatewayAcpClientInternal + .loadCapabilities( + forceRefresh: true, + endpointOverride: endpoint, + authorizationOverride: authorization, + ); + if (!mounted) { + return; + } + setStateInternal(() { + acpBridgeServerSelfHostedMessageInternal = + describeExternalAcpTestSuccess(capabilities); + }); + } catch (error) { + if (!mounted) { + return; + } + setStateInternal(() { + acpBridgeServerSelfHostedMessageInternal = + describeExternalAcpTestFailure(error, endpoint: endpoint); + }); + } finally { + if (mounted) { + setStateInternal(() { + acpBridgeServerSelfHostedTestingInternal = false; + }); + } + } + } + + Future resetAcpBridgeServerAdvancedOverridesInternal( + AppController controller, + SettingsSnapshot settings, + ) async { + var next = settings.copyWith( + gatewayProfiles: SettingsSnapshot.defaults().gatewayProfiles, + vault: VaultConfig.defaults(), + aiGateway: AiGatewayProfile.defaults(), + externalAcpEndpoints: SettingsSnapshot.defaults().externalAcpEndpoints, + authorizedSkillDirectories: + SettingsSnapshot.defaults().authorizedSkillDirectories, + acpBridgeServerModeConfig: settings.acpBridgeServerModeConfig.copyWith( + mode: settings.acpBridgeServerModeConfig.usesSelfHostedBase + ? AcpBridgeServerMode.selfHosted + : AcpBridgeServerMode.cloudSynced, + ), + ); + await saveSettingsInternal(controller, next); + if (controller.settingsController.accountSignedIn && + next.acpBridgeServerModeConfig.usesCloudSyncBase) { + await controller.settingsController.syncAccountSettings( + baseUrl: next.accountBaseUrl, + ); + } + } + Widget buildExternalAcpProviderCardInternal( BuildContext context, AppController controller, diff --git a/lib/features/settings/settings_page_sections.dart b/lib/features/settings/settings_page_sections.dart index e206219f..2354edab 100644 --- a/lib/features/settings/settings_page_sections.dart +++ b/lib/features/settings/settings_page_sections.dart @@ -306,15 +306,6 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal { settings.copyWith(showDockIcon: value), ), ), - if (uiFeatures.supportsAccountAccess) - SwitchRowInternal( - label: appText('账号本地模式', 'Account local mode'), - value: settings.accountLocalMode, - onChanged: (value) => saveSettingsInternal( - controller, - settings.copyWith(accountLocalMode: value), - ), - ), ], ), ), diff --git a/lib/features/settings/settings_page_support.dart b/lib/features/settings/settings_page_support.dart index 9a16ba78..26e00cfd 100644 --- a/lib/features/settings/settings_page_support.dart +++ b/lib/features/settings/settings_page_support.dart @@ -407,6 +407,27 @@ XWorkmate Privacy Policy ); } + void syncAcpBridgeServerModeDraftControllersInternal( + SettingsSnapshot settings, + ) { + final selfHosted = settings.acpBridgeServerModeConfig.selfHosted; + syncDraftControllerValueInternal( + acpBridgeServerUrlControllerInternal, + selfHosted.serverUrl, + syncedValue: acpBridgeServerUrlSyncedValueInternal, + onSyncedValueChanged: (value) => + acpBridgeServerUrlSyncedValueInternal = value, + ); + syncDraftControllerValueInternal( + acpBridgeServerUsernameControllerInternal, + selfHosted.username, + syncedValue: acpBridgeServerUsernameSyncedValueInternal, + onSyncedValueChanged: (value) => + acpBridgeServerUsernameSyncedValueInternal = value, + ); + acpBridgeServerPasswordRefSyncedValueInternal = selfHosted.passwordRef; + } + void disposeRemovedExternalAcpDraftsInternal( Map controllers, Set activeKeys, diff --git a/lib/runtime/runtime_controllers_settings_account.dart b/lib/runtime/runtime_controllers_settings_account.dart index 461df04c..ee3223b8 100644 --- a/lib/runtime/runtime_controllers_settings_account.dart +++ b/lib/runtime/runtime_controllers_settings_account.dart @@ -25,7 +25,7 @@ extension SettingsControllerAccountExtension on SettingsController { if (local.isNotEmpty) { return local; } - if (snapshotInternal.accountLocalMode) { + if (!snapshotInternal.acpBridgeServerModeConfig.usesCloudSyncBase) { return ''; } return accountSyncStateInternal?.syncedDefaults.apisixUrl.trim() ?? ''; diff --git a/lib/runtime/runtime_controllers_settings_account_impl.dart b/lib/runtime/runtime_controllers_settings_account_impl.dart index d7c2ea12..bb690fc9 100644 --- a/lib/runtime/runtime_controllers_settings_account_impl.dart +++ b/lib/runtime/runtime_controllers_settings_account_impl.dart @@ -268,6 +268,35 @@ Future syncAccountSettingsInternal( ); await controller.storeInternal.saveAccountSyncState(nextState); await controller.storeInternal.clearAccountProfile(); + final currentSettings = controller.snapshotInternal; + final currentModeConfig = currentSettings.acpBridgeServerModeConfig; + final nextModeConfig = currentModeConfig.copyWith( + cloudSynced: currentModeConfig.cloudSynced.copyWith( + accountBaseUrl: normalizedBaseUrl, + accountIdentifier: currentSettings.accountUsername.trim().isNotEmpty + ? currentSettings.accountUsername.trim() + : controller.accountSessionInternal?.email.trim() ?? '', + lastSyncAt: nextState.lastSyncAtMs, + remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary + .copyWith( + endpoint: response.profile.openclawUrl.trim().isNotEmpty + ? response.profile.openclawUrl.trim() + : response.profile.apisixUrl.trim(), + hasAdvancedOverrides: + currentModeConfig.mode == AcpBridgeServerMode.advancedCustom, + ), + ), + ); + if (nextModeConfig.toJson().toString() != + currentModeConfig.toJson().toString()) { + await controller.saveSnapshot( + currentSettings.copyWith( + accountLocalMode: false, + acpBridgeServerModeConfig: nextModeConfig, + ), + recordAccountOverrides: false, + ); + } await applyAccountSyncedDefaultsSettingsInternal( controller, state: nextState, @@ -400,6 +429,25 @@ Future applyAccountSyncedDefaultsSettingsInternal( if (next.accountLocalMode) { next = next.copyWith(accountLocalMode: false); } + next = next.copyWith( + acpBridgeServerModeConfig: next.acpBridgeServerModeConfig.copyWith( + cloudSynced: next.acpBridgeServerModeConfig.cloudSynced.copyWith( + accountBaseUrl: next.accountBaseUrl, + accountIdentifier: next.accountUsername, + lastSyncAt: state.lastSyncAtMs, + remoteServerSummary: + next.acpBridgeServerModeConfig.cloudSynced.remoteServerSummary + .copyWith( + endpoint: defaults.openclawUrl.trim().isNotEmpty + ? defaults.openclawUrl.trim() + : defaults.apisixUrl.trim(), + hasAdvancedOverrides: + next.acpBridgeServerModeConfig.mode == + AcpBridgeServerMode.advancedCustom, + ), + ), + ), + ); if (next.toJsonString() != previous.toJsonString()) { await controller.saveSnapshot(next, recordAccountOverrides: false); @@ -424,7 +472,17 @@ Future logoutAccountSettingsInternal( await controller.storeInternal.clearAccountSessionSummary(); if (!controller.snapshotInternal.accountLocalMode) { await controller.saveSnapshot( - controller.snapshotInternal.copyWith(accountLocalMode: true), + controller.snapshotInternal.copyWith( + accountLocalMode: true, + acpBridgeServerModeConfig: + controller.snapshotInternal.acpBridgeServerModeConfig.copyWith( + cloudSynced: controller + .snapshotInternal + .acpBridgeServerModeConfig + .cloudSynced + .copyWith(accountIdentifier: ''), + ), + ), recordAccountOverrides: false, ); } else { diff --git a/lib/runtime/runtime_models_account.dart b/lib/runtime/runtime_models_account.dart index d569a185..f08cca7d 100644 --- a/lib/runtime/runtime_models_account.dart +++ b/lib/runtime/runtime_models_account.dart @@ -1,3 +1,6 @@ +import 'runtime_models_configs.dart'; +import 'runtime_models_profiles.dart'; + class AccountSessionSummary { const AccountSessionSummary({ required this.userId, @@ -255,6 +258,346 @@ class AccountRemoteProfile { } } +enum AcpBridgeServerMode { cloudSynced, selfHosted, advancedCustom } + +class AcpBridgeServerRemoteServerSummary { + const AcpBridgeServerRemoteServerSummary({ + required this.endpoint, + required this.hasAdvancedOverrides, + }); + + final String endpoint; + final bool hasAdvancedOverrides; + + factory AcpBridgeServerRemoteServerSummary.defaults() { + return const AcpBridgeServerRemoteServerSummary( + endpoint: '', + hasAdvancedOverrides: false, + ); + } + + AcpBridgeServerRemoteServerSummary copyWith({ + String? endpoint, + bool? hasAdvancedOverrides, + }) { + return AcpBridgeServerRemoteServerSummary( + endpoint: endpoint ?? this.endpoint, + hasAdvancedOverrides: hasAdvancedOverrides ?? this.hasAdvancedOverrides, + ); + } + + Map toJson() { + return { + 'endpoint': endpoint, + 'hasAdvancedOverrides': hasAdvancedOverrides, + }; + } + + factory AcpBridgeServerRemoteServerSummary.fromJson( + Map json, + ) { + return AcpBridgeServerRemoteServerSummary( + endpoint: json['endpoint'] as String? ?? '', + hasAdvancedOverrides: json['hasAdvancedOverrides'] as bool? ?? false, + ); + } +} + +class AcpBridgeServerCloudSyncConfig { + const AcpBridgeServerCloudSyncConfig({ + required this.accountBaseUrl, + required this.accountIdentifier, + required this.lastSyncAt, + required this.remoteServerSummary, + }); + + final String accountBaseUrl; + final String accountIdentifier; + final int lastSyncAt; + final AcpBridgeServerRemoteServerSummary remoteServerSummary; + + factory AcpBridgeServerCloudSyncConfig.defaults() { + return AcpBridgeServerCloudSyncConfig( + accountBaseUrl: '', + accountIdentifier: '', + lastSyncAt: 0, + remoteServerSummary: AcpBridgeServerRemoteServerSummary.defaults(), + ); + } + + AcpBridgeServerCloudSyncConfig copyWith({ + String? accountBaseUrl, + String? accountIdentifier, + int? lastSyncAt, + AcpBridgeServerRemoteServerSummary? remoteServerSummary, + }) { + return AcpBridgeServerCloudSyncConfig( + accountBaseUrl: accountBaseUrl ?? this.accountBaseUrl, + accountIdentifier: accountIdentifier ?? this.accountIdentifier, + lastSyncAt: lastSyncAt ?? this.lastSyncAt, + remoteServerSummary: remoteServerSummary ?? this.remoteServerSummary, + ); + } + + Map toJson() { + return { + 'accountBaseUrl': accountBaseUrl, + 'accountIdentifier': accountIdentifier, + 'lastSyncAt': lastSyncAt, + 'remoteServerSummary': remoteServerSummary.toJson(), + }; + } + + factory AcpBridgeServerCloudSyncConfig.fromJson(Map json) { + return AcpBridgeServerCloudSyncConfig( + accountBaseUrl: json['accountBaseUrl'] as String? ?? '', + accountIdentifier: json['accountIdentifier'] as String? ?? '', + lastSyncAt: (json['lastSyncAt'] as num?)?.toInt() ?? 0, + remoteServerSummary: AcpBridgeServerRemoteServerSummary.fromJson( + (json['remoteServerSummary'] as Map?)?.cast() ?? + const {}, + ), + ); + } +} + +class AcpBridgeServerSelfHostedConfig { + const AcpBridgeServerSelfHostedConfig({ + required this.serverUrl, + required this.username, + required this.passwordRef, + }); + + final String serverUrl; + final String username; + final String passwordRef; + + factory AcpBridgeServerSelfHostedConfig.defaults() { + return const AcpBridgeServerSelfHostedConfig( + serverUrl: '', + username: '', + passwordRef: 'acp_bridge_server_password', + ); + } + + AcpBridgeServerSelfHostedConfig copyWith({ + String? serverUrl, + String? username, + String? passwordRef, + }) { + return AcpBridgeServerSelfHostedConfig( + serverUrl: (serverUrl ?? this.serverUrl).trim(), + username: (username ?? this.username).trim(), + passwordRef: (passwordRef ?? this.passwordRef).trim(), + ); + } + + bool get isConfigured => + serverUrl.trim().isNotEmpty && username.trim().isNotEmpty; + + Map toJson() { + return { + 'serverUrl': serverUrl, + 'username': username, + 'passwordRef': passwordRef, + }; + } + + factory AcpBridgeServerSelfHostedConfig.fromJson(Map json) { + return AcpBridgeServerSelfHostedConfig( + serverUrl: json['serverUrl'] as String? ?? '', + username: json['username'] as String? ?? '', + passwordRef: + json['passwordRef'] as String? ?? + AcpBridgeServerSelfHostedConfig.defaults().passwordRef, + ); + } +} + +class AcpBridgeServerAdvancedOverrides { + const AcpBridgeServerAdvancedOverrides({ + required this.gatewayProfiles, + required this.vault, + required this.aiGateway, + required this.acpBridgeServerProfiles, + required this.authorizedSkillDirectories, + }); + + final List gatewayProfiles; + final VaultConfig vault; + final AiGatewayProfile aiGateway; + final List acpBridgeServerProfiles; + final List authorizedSkillDirectories; + + factory AcpBridgeServerAdvancedOverrides.defaults() { + return AcpBridgeServerAdvancedOverrides( + gatewayProfiles: normalizeGatewayProfiles(), + vault: VaultConfig.defaults(), + aiGateway: AiGatewayProfile.defaults(), + acpBridgeServerProfiles: normalizeExternalAcpEndpoints(), + authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(), + ); + } + + AcpBridgeServerAdvancedOverrides copyWith({ + List? gatewayProfiles, + VaultConfig? vault, + AiGatewayProfile? aiGateway, + List? acpBridgeServerProfiles, + List? authorizedSkillDirectories, + }) { + return AcpBridgeServerAdvancedOverrides( + gatewayProfiles: gatewayProfiles != null + ? normalizeGatewayProfiles(profiles: gatewayProfiles) + : this.gatewayProfiles, + vault: vault ?? this.vault, + aiGateway: aiGateway ?? this.aiGateway, + acpBridgeServerProfiles: acpBridgeServerProfiles != null + ? normalizeExternalAcpEndpoints(profiles: acpBridgeServerProfiles) + : this.acpBridgeServerProfiles, + authorizedSkillDirectories: authorizedSkillDirectories != null + ? normalizeAuthorizedSkillDirectories( + directories: authorizedSkillDirectories, + ) + : this.authorizedSkillDirectories, + ); + } + + Map toJson() { + return { + 'gatewayProfiles': gatewayProfiles + .map((item) => item.toJson()) + .toList(growable: false), + 'vault': vault.toJson(), + 'aiGateway': aiGateway.toJson(), + 'acpBridgeServerProfiles': acpBridgeServerProfiles + .map((item) => item.toJson()) + .toList(growable: false), + 'authorizedSkillDirectories': authorizedSkillDirectories + .map((item) => item.toJson()) + .toList(growable: false), + }; + } + + factory AcpBridgeServerAdvancedOverrides.fromJson( + Map json, + ) { + return AcpBridgeServerAdvancedOverrides( + gatewayProfiles: normalizeGatewayProfiles( + profiles: ((json['gatewayProfiles'] as List?) ?? const []) + .whereType() + .map( + (item) => GatewayConnectionProfile.fromJson( + item.cast(), + ), + ), + ), + vault: VaultConfig.fromJson( + (json['vault'] as Map?)?.cast() ?? const {}, + ), + aiGateway: AiGatewayProfile.fromJson( + (json['aiGateway'] as Map?)?.cast() ?? const {}, + ), + acpBridgeServerProfiles: normalizeExternalAcpEndpoints( + profiles: + ((json['acpBridgeServerProfiles'] as List?) ?? const []) + .whereType() + .map( + (item) => ExternalAcpEndpointProfile.fromJson( + item.cast(), + ), + ), + ), + authorizedSkillDirectories: normalizeAuthorizedSkillDirectories( + directories: + ((json['authorizedSkillDirectories'] as List?) ?? const []) + .whereType() + .map( + (item) => AuthorizedSkillDirectory.fromJson( + item.cast(), + ), + ), + ), + ); + } +} + +class AcpBridgeServerModeConfig { + const AcpBridgeServerModeConfig({ + required this.mode, + required this.cloudSynced, + required this.selfHosted, + required this.advancedOverrides, + }); + + final AcpBridgeServerMode mode; + final AcpBridgeServerCloudSyncConfig cloudSynced; + final AcpBridgeServerSelfHostedConfig selfHosted; + final AcpBridgeServerAdvancedOverrides advancedOverrides; + + factory AcpBridgeServerModeConfig.defaults() { + return AcpBridgeServerModeConfig( + mode: AcpBridgeServerMode.cloudSynced, + cloudSynced: AcpBridgeServerCloudSyncConfig.defaults(), + selfHosted: AcpBridgeServerSelfHostedConfig.defaults(), + advancedOverrides: AcpBridgeServerAdvancedOverrides.defaults(), + ); + } + + AcpBridgeServerModeConfig copyWith({ + AcpBridgeServerMode? mode, + AcpBridgeServerCloudSyncConfig? cloudSynced, + AcpBridgeServerSelfHostedConfig? selfHosted, + AcpBridgeServerAdvancedOverrides? advancedOverrides, + }) { + return AcpBridgeServerModeConfig( + mode: mode ?? this.mode, + cloudSynced: cloudSynced ?? this.cloudSynced, + selfHosted: selfHosted ?? this.selfHosted, + advancedOverrides: advancedOverrides ?? this.advancedOverrides, + ); + } + + bool get usesSelfHostedBase => + mode == AcpBridgeServerMode.selfHosted || + (mode == AcpBridgeServerMode.advancedCustom && selfHosted.isConfigured); + + bool get usesCloudSyncBase => !usesSelfHostedBase; + + String get sourceTag => switch (mode) { + AcpBridgeServerMode.cloudSynced => 'cloudSynced', + AcpBridgeServerMode.selfHosted => 'selfHosted', + AcpBridgeServerMode.advancedCustom => 'advancedOverride', + }; + + Map toJson() { + return { + 'mode': mode.name, + 'cloudSynced': cloudSynced.toJson(), + 'selfHosted': selfHosted.toJson(), + 'advancedOverrides': advancedOverrides.toJson(), + }; + } + + factory AcpBridgeServerModeConfig.fromJson(Map json) { + return AcpBridgeServerModeConfig( + mode: AcpBridgeServerMode.values.firstWhere( + (item) => item.name == json['mode'], + orElse: () => AcpBridgeServerMode.cloudSynced, + ), + cloudSynced: AcpBridgeServerCloudSyncConfig.fromJson( + (json['cloudSynced'] as Map?)?.cast() ?? const {}, + ), + selfHosted: AcpBridgeServerSelfHostedConfig.fromJson( + (json['selfHosted'] as Map?)?.cast() ?? const {}, + ), + advancedOverrides: AcpBridgeServerAdvancedOverrides.fromJson( + (json['advancedOverrides'] as Map?)?.cast() ?? const {}, + ), + ); + } +} + class AccountProfileResponse { const AccountProfileResponse({ required this.profile, diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index 59be1279..e9eb974b 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; +import 'runtime_models_account.dart'; import 'runtime_models_connection.dart'; import 'runtime_models_profiles.dart'; import 'runtime_models_configs.dart'; @@ -40,6 +41,7 @@ class SettingsSnapshot { required this.accountWorkspace, required this.accountWorkspaceFollowed, required this.accountLocalMode, + required this.acpBridgeServerModeConfig, required this.linuxDesktop, required this.assistantExecutionTarget, required this.assistantPermissionLevel, @@ -78,6 +80,7 @@ class SettingsSnapshot { final String accountWorkspace; final bool accountWorkspaceFollowed; final bool accountLocalMode; + final AcpBridgeServerModeConfig acpBridgeServerModeConfig; final LinuxDesktopConfig linuxDesktop; final AssistantExecutionTarget assistantExecutionTarget; final AssistantPermissionLevel assistantPermissionLevel; @@ -117,6 +120,7 @@ class SettingsSnapshot { accountWorkspace: 'Default Workspace', accountWorkspaceFollowed: false, accountLocalMode: true, + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults(), linuxDesktop: LinuxDesktopConfig.defaults(), assistantExecutionTarget: AssistantExecutionTarget.singleAgent, assistantPermissionLevel: AssistantPermissionLevel.defaultAccess, @@ -157,6 +161,7 @@ class SettingsSnapshot { String? accountWorkspace, bool? accountWorkspaceFollowed, bool? accountLocalMode, + AcpBridgeServerModeConfig? acpBridgeServerModeConfig, LinuxDesktopConfig? linuxDesktop, AssistantExecutionTarget? assistantExecutionTarget, AssistantPermissionLevel? assistantPermissionLevel, @@ -209,6 +214,8 @@ class SettingsSnapshot { accountWorkspaceFollowed: accountWorkspaceFollowed ?? this.accountWorkspaceFollowed, accountLocalMode: accountLocalMode ?? this.accountLocalMode, + acpBridgeServerModeConfig: + acpBridgeServerModeConfig ?? this.acpBridgeServerModeConfig, linuxDesktop: linuxDesktop ?? this.linuxDesktop, assistantExecutionTarget: assistantExecutionTarget ?? this.assistantExecutionTarget, @@ -265,6 +272,7 @@ class SettingsSnapshot { 'accountWorkspace': accountWorkspace, 'accountWorkspaceFollowed': accountWorkspaceFollowed, 'accountLocalMode': accountLocalMode, + 'acpBridgeServerModeConfig': acpBridgeServerModeConfig.toJson(), 'linuxDesktop': linuxDesktop.toJson(), 'assistantExecutionTarget': assistantExecutionTarget.name, 'assistantPermissionLevel': assistantPermissionLevel.name, @@ -424,6 +432,10 @@ class SettingsSnapshot { accountWorkspaceFollowed: json['accountWorkspaceFollowed'] as bool? ?? false, accountLocalMode: json['accountLocalMode'] as bool? ?? true, + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.fromJson( + (json['acpBridgeServerModeConfig'] as Map?)?.cast() ?? + const {}, + ), linuxDesktop: LinuxDesktopConfig.fromJson( (json['linuxDesktop'] as Map?)?.cast() ?? const {}, ), @@ -653,6 +665,20 @@ class SettingsSnapshot { ), ); } + + SettingsSnapshot captureAcpBridgeServerAdvancedOverrides() { + return copyWith( + acpBridgeServerModeConfig: acpBridgeServerModeConfig.copyWith( + advancedOverrides: AcpBridgeServerAdvancedOverrides( + gatewayProfiles: gatewayProfiles, + vault: vault, + aiGateway: aiGateway, + acpBridgeServerProfiles: externalAcpEndpoints, + authorizedSkillDirectories: authorizedSkillDirectories, + ), + ), + ); + } } List normalizeSavedGatewayTargets(Iterable rawTargets) { diff --git a/test/features/account_page_auth_suite.dart b/test/features/account_page_auth_suite.dart index 2302cc2e..f3d4af44 100644 --- a/test/features/account_page_auth_suite.dart +++ b/test/features/account_page_auth_suite.dart @@ -27,6 +27,10 @@ void main() { expect(find.byKey(const ValueKey('account-password-field')), findsOneWidget); expect(find.widgetWithText(FilledButton, '登录'), findsOneWidget); expect(find.text('保存本地入口'), findsNothing); + expect( + find.byKey(const ValueKey('account-open-settings-acp')), + findsNothing, + ); }); testWidgets('AccountPage logs in and shows remote sync status inline', ( @@ -76,6 +80,11 @@ void main() { expect(find.byKey(const ValueKey('account-login-button')), findsNothing); expect(find.byKey(const ValueKey('account-sync-button')), findsOneWidget); expect(find.byKey(const ValueKey('account-logout-button')), findsOneWidget); + expect(find.byKey(const ValueKey('account-open-settings-acp')), findsOneWidget); + expect( + find.byKey(const ValueKey('account-acp-sync-summary-endpoint')), + findsOneWidget, + ); }); testWidgets('AccountPage completes MFA verification and can log out', ( diff --git a/test/features/assistant_page_single_agent_flow_suite.dart b/test/features/assistant_page_single_agent_flow_suite.dart index 3b01ab38..dd96a9e2 100644 --- a/test/features/assistant_page_single_agent_flow_suite.dart +++ b/test/features/assistant_page_single_agent_flow_suite.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -21,7 +20,7 @@ Future _waitForText( final deadline = DateTime.now().add(timeout); while (finder.evaluate().isEmpty) { if (DateTime.now().isAfter(deadline)) { - fail('Timed out waiting for ${finder.description}'); + fail('Timed out waiting for expected widget.'); } await tester.pump(const Duration(milliseconds: 50)); } diff --git a/test/features/assistant_page_suite_support.dart b/test/features/assistant_page_suite_support.dart index 09d6c516..bd176dd8 100644 --- a/test/features/assistant_page_suite_support.dart +++ b/test/features/assistant_page_suite_support.dart @@ -456,6 +456,7 @@ SettingsSnapshot buildAssistantPageTestSettingsSnapshotInternal( accountWorkspace: defaults.accountWorkspace, accountWorkspaceFollowed: defaults.accountWorkspaceFollowed, accountLocalMode: defaults.accountLocalMode, + acpBridgeServerModeConfig: defaults.acpBridgeServerModeConfig, linuxDesktop: defaults.linuxDesktop, assistantExecutionTarget: assistantExecutionTarget, assistantPermissionLevel: defaults.assistantPermissionLevel, diff --git a/test/features/settings_page_acp_bridge_mode_suite.dart b/test/features/settings_page_acp_bridge_mode_suite.dart new file mode 100644 index 00000000..7214b6f5 --- /dev/null +++ b/test/features/settings_page_acp_bridge_mode_suite.dart @@ -0,0 +1,37 @@ +@TestOn('vm') +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/features/settings/settings_page_core.dart'; +import 'package:xworkmate/models/app_models.dart'; + +import '../test_support.dart'; + +void main() { + testWidgets('SettingsPage shows ACP bridge server mode card on integrations', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + controller.openSettings(tab: SettingsTab.gateway); + + await pumpPage( + tester, + child: SettingsPage( + controller: controller, + initialTab: SettingsTab.gateway, + ), + ); + + expect(find.text('XWorkmate ACP Bridge Server'), findsOneWidget); + expect(find.byKey(const ValueKey('acp-bridge-mode-cloud')), findsOneWidget); + expect( + find.byKey(const ValueKey('acp-bridge-mode-self-hosted')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('acp-bridge-mode-advanced')), + findsOneWidget, + ); + }); +} diff --git a/test/features/settings_page_external_acp_end_to_end_suite.dart b/test/features/settings_page_external_acp_end_to_end_suite.dart index 59698ed1..c80979d4 100644 --- a/test/features/settings_page_external_acp_end_to_end_suite.dart +++ b/test/features/settings_page_external_acp_end_to_end_suite.dart @@ -19,7 +19,7 @@ Future _waitForText( final deadline = DateTime.now().add(timeout); while (finder.evaluate().isEmpty) { if (DateTime.now().isAfter(deadline)) { - fail('Timed out waiting for ${finder.description}'); + fail('Timed out waiting for expected widget.'); } await tester.pump(const Duration(milliseconds: 50)); } diff --git a/test/runtime/acp_bridge_server_mode_config_suite.dart b/test/runtime/acp_bridge_server_mode_config_suite.dart new file mode 100644 index 00000000..96a6f6e2 --- /dev/null +++ b/test/runtime/acp_bridge_server_mode_config_suite.dart @@ -0,0 +1,77 @@ +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + test('AcpBridgeServerModeConfig defaults to cloud synced mode', () { + final config = AcpBridgeServerModeConfig.defaults(); + + expect(config.mode, AcpBridgeServerMode.cloudSynced); + expect(config.usesCloudSyncBase, isTrue); + expect(config.usesSelfHostedBase, isFalse); + expect(config.selfHosted.passwordRef, 'acp_bridge_server_password'); + }); + + test('advanced custom mode can inherit self hosted base when configured', () { + final config = AcpBridgeServerModeConfig.defaults().copyWith( + mode: AcpBridgeServerMode.advancedCustom, + selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( + serverUrl: 'https://bridge.example.com', + username: 'review', + ), + ); + + expect(config.usesSelfHostedBase, isTrue); + expect(config.usesCloudSyncBase, isFalse); + expect(config.sourceTag, 'advancedOverride'); + }); + + test('SettingsSnapshot captures current advanced overrides into mode config', () { + final snapshot = SettingsSnapshot.defaults().copyWith( + gatewayProfiles: SettingsSnapshot.defaults().gatewayProfiles, + vault: VaultConfig.defaults().copyWith(address: 'https://vault.example'), + aiGateway: AiGatewayProfile.defaults().copyWith( + baseUrl: 'https://llm.example.com/v1', + ), + externalAcpEndpoints: [ + ExternalAcpEndpointProfile.defaultsForProvider( + SingleAgentProvider.codex, + ).copyWith(endpoint: 'https://agent.example.com'), + ], + authorizedSkillDirectories: const [ + AuthorizedSkillDirectory(path: '/tmp/skills'), + ], + ); + + final captured = snapshot.captureAcpBridgeServerAdvancedOverrides(); + + expect( + captured.acpBridgeServerModeConfig.advancedOverrides.vault.address, + 'https://vault.example', + ); + expect( + captured.acpBridgeServerModeConfig.advancedOverrides.aiGateway.baseUrl, + 'https://llm.example.com/v1', + ); + expect( + captured + .acpBridgeServerModeConfig + .advancedOverrides + .acpBridgeServerProfiles + .firstWhere((item) => item.providerKey == 'codex') + .endpoint, + 'https://agent.example.com', + ); + expect( + captured + .acpBridgeServerModeConfig + .advancedOverrides + .authorizedSkillDirectories + .single + .path, + '/tmp/skills', + ); + }); +} diff --git a/test/runtime/acp_bridge_server_self_hosted_secret_suite.dart b/test/runtime/acp_bridge_server_self_hosted_secret_suite.dart new file mode 100644 index 00000000..9c9589ef --- /dev/null +++ b/test/runtime/acp_bridge_server_self_hosted_secret_suite.dart @@ -0,0 +1,55 @@ +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + test('self hosted ACP bridge password stays in secure storage, not settings snapshot', () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-acp-bridge-self-hosted-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: () async => tempDirectory.path, + ); + addTearDown(store.dispose); + await store.initialize(); + + final snapshot = SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults().copyWith( + mode: AcpBridgeServerMode.selfHosted, + selfHosted: AcpBridgeServerSelfHostedConfig.defaults().copyWith( + serverUrl: 'https://bridge.example.com', + username: 'review@example.com', + ), + ), + ); + await store.saveSettingsSnapshot(snapshot); + await store.saveSecretValueByRef('acp_bridge_server_password', 'top-secret'); + + final loadedSnapshot = await store.loadSettingsSnapshot(); + + expect( + loadedSnapshot.acpBridgeServerModeConfig.selfHosted.passwordRef, + 'acp_bridge_server_password', + ); + expect(loadedSnapshot.toJsonString(), isNot(contains('top-secret'))); + expect( + await store.loadSecretValueByRef('acp_bridge_server_password'), + 'top-secret', + ); + }); +} diff --git a/test/runtime/settings_controller_account_sync_suite.dart b/test/runtime/settings_controller_account_sync_suite.dart index ce63ac7a..9fb3ea1e 100644 --- a/test/runtime/settings_controller_account_sync_suite.dart +++ b/test/runtime/settings_controller_account_sync_suite.dart @@ -84,6 +84,19 @@ void main() { kAccountManagedSecretTargetOllamaCloudApiKey, ); expect(controller.snapshot.accountLocalMode, isFalse); + expect( + controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountBaseUrl, + server.accountBaseUrl, + ); + expect( + controller + .snapshot + .acpBridgeServerModeConfig + .cloudSynced + .remoteServerSummary + .endpoint, + 'https://openclaw.account.example', + ); expect( (await store.loadSettingsSnapshot()).toJsonString(), allOf( @@ -262,6 +275,10 @@ void main() { expect(await store.loadAccountSyncState(), isNotNull); expect(controller.snapshot.aiGateway.baseUrl, 'https://local-ai.example.com/v1'); expect(controller.snapshot.accountLocalMode, isTrue); + expect( + controller.snapshot.acpBridgeServerModeConfig.cloudSynced.accountIdentifier, + '', + ); expect( (await store.loadAccountSyncState()) ?.overrideFlags[kAccountOverrideAiGatewayBaseUrl],