Merge branch 'codex/acp-bridge-modes'

This commit is contained in:
Haitao Pan 2026-04-09 07:15:23 +08:00
commit adf3f6af83
20 changed files with 1342 additions and 137 deletions

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../test/helpers/test_keys.dart';

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../test/helpers/test_keys.dart';

View File

@ -229,7 +229,13 @@ class _AccountPageState extends State<AccountPage> {
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<AccountPage> {
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<AccountPage> {
: '${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<AccountPage> {
profileDescription,
sessionStatusText,
syncStatusText,
accountSyncState,
),
if (_tab == AccountTab.workspace)
SurfaceCard(

View File

@ -70,6 +70,9 @@ class SettingsPageStateInternal extends State<SettingsPage> {
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<String, TextEditingController>
externalAcpLabelControllersInternal;
late final Map<String, TextEditingController>
@ -102,6 +105,11 @@ class SettingsPageStateInternal extends State<SettingsPage> {
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<SettingsPage> {
vaultTokenControllerInternal = TextEditingController();
ollamaApiKeyControllerInternal = TextEditingController();
runtimeLogFilterControllerInternal = TextEditingController();
acpBridgeServerUrlControllerInternal = TextEditingController();
acpBridgeServerUsernameControllerInternal = TextEditingController();
acpBridgeServerPasswordControllerInternal = TextEditingController();
externalAcpLabelControllersInternal = <String, TextEditingController>{};
externalAcpEndpointControllersInternal = <String, TextEditingController>{};
externalAcpAuthControllersInternal = <String, TextEditingController>{};
@ -251,6 +262,9 @@ class SettingsPageStateInternal extends State<SettingsPage> {
vaultTokenControllerInternal.dispose();
ollamaApiKeyControllerInternal.dispose();
runtimeLogFilterControllerInternal.dispose();
acpBridgeServerUrlControllerInternal.dispose();
acpBridgeServerUsernameControllerInternal.dispose();
acpBridgeServerPasswordControllerInternal.dispose();
for (final controller in externalAcpLabelControllersInternal.values) {
controller.dispose();
}

View File

@ -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: <String>[
'OpenClaw Gateway',
@ -88,33 +93,45 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
const SizedBox(height: 16),
...switch (integrationSubTabInternal) {
GatewayIntegrationSubTabInternal.gateway => <Widget>[
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 => <Widget>[
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 => <Widget>[
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 => <Widget>[
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 => <Widget>[
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<Widget> 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 => <Widget>[
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 => <Widget>[
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<Widget> 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,
),
),
];

View File

@ -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 => <Widget>[
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 => <Widget>[
buildAcpBridgeServerSelfHostedPanelInternal(
context,
controller,
settings,
),
],
AcpBridgeServerMode.advancedCustom => <Widget>[
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<void> 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<void> connectAcpBridgeServerSelfHostedInternal(
AppController controller,
SettingsSnapshot settings,
) async {
await saveAcpBridgeServerSelfHostedInternal(controller, settings);
if (!mounted) {
return;
}
await testAcpBridgeServerSelfHostedInternal(controller);
}
Future<void> 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<void> 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,

View File

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

View File

@ -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<String, TextEditingController> controllers,
Set<String> activeKeys,

View File

@ -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() ?? '';

View File

@ -268,6 +268,35 @@ Future<AccountSyncResult> 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<void> 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<void> 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 {

View File

@ -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<String, dynamic> toJson() {
return <String, dynamic>{
'endpoint': endpoint,
'hasAdvancedOverrides': hasAdvancedOverrides,
};
}
factory AcpBridgeServerRemoteServerSummary.fromJson(
Map<String, dynamic> 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<String, dynamic> toJson() {
return <String, dynamic>{
'accountBaseUrl': accountBaseUrl,
'accountIdentifier': accountIdentifier,
'lastSyncAt': lastSyncAt,
'remoteServerSummary': remoteServerSummary.toJson(),
};
}
factory AcpBridgeServerCloudSyncConfig.fromJson(Map<String, dynamic> 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<String, dynamic>() ??
const <String, dynamic>{},
),
);
}
}
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<String, dynamic> toJson() {
return <String, dynamic>{
'serverUrl': serverUrl,
'username': username,
'passwordRef': passwordRef,
};
}
factory AcpBridgeServerSelfHostedConfig.fromJson(Map<String, dynamic> 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<GatewayConnectionProfile> gatewayProfiles;
final VaultConfig vault;
final AiGatewayProfile aiGateway;
final List<ExternalAcpEndpointProfile> acpBridgeServerProfiles;
final List<AuthorizedSkillDirectory> authorizedSkillDirectories;
factory AcpBridgeServerAdvancedOverrides.defaults() {
return AcpBridgeServerAdvancedOverrides(
gatewayProfiles: normalizeGatewayProfiles(),
vault: VaultConfig.defaults(),
aiGateway: AiGatewayProfile.defaults(),
acpBridgeServerProfiles: normalizeExternalAcpEndpoints(),
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(),
);
}
AcpBridgeServerAdvancedOverrides copyWith({
List<GatewayConnectionProfile>? gatewayProfiles,
VaultConfig? vault,
AiGatewayProfile? aiGateway,
List<ExternalAcpEndpointProfile>? acpBridgeServerProfiles,
List<AuthorizedSkillDirectory>? 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<String, dynamic> toJson() {
return <String, dynamic>{
'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<String, dynamic> json,
) {
return AcpBridgeServerAdvancedOverrides(
gatewayProfiles: normalizeGatewayProfiles(
profiles: ((json['gatewayProfiles'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => GatewayConnectionProfile.fromJson(
item.cast<String, dynamic>(),
),
),
),
vault: VaultConfig.fromJson(
(json['vault'] as Map?)?.cast<String, dynamic>() ?? const {},
),
aiGateway: AiGatewayProfile.fromJson(
(json['aiGateway'] as Map?)?.cast<String, dynamic>() ?? const {},
),
acpBridgeServerProfiles: normalizeExternalAcpEndpoints(
profiles:
((json['acpBridgeServerProfiles'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => ExternalAcpEndpointProfile.fromJson(
item.cast<String, dynamic>(),
),
),
),
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(
directories:
((json['authorizedSkillDirectories'] as List?) ?? const <Object>[])
.whereType<Map>()
.map(
(item) => AuthorizedSkillDirectory.fromJson(
item.cast<String, dynamic>(),
),
),
),
);
}
}
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<String, dynamic> toJson() {
return <String, dynamic>{
'mode': mode.name,
'cloudSynced': cloudSynced.toJson(),
'selfHosted': selfHosted.toJson(),
'advancedOverrides': advancedOverrides.toJson(),
};
}
factory AcpBridgeServerModeConfig.fromJson(Map<String, dynamic> json) {
return AcpBridgeServerModeConfig(
mode: AcpBridgeServerMode.values.firstWhere(
(item) => item.name == json['mode'],
orElse: () => AcpBridgeServerMode.cloudSynced,
),
cloudSynced: AcpBridgeServerCloudSyncConfig.fromJson(
(json['cloudSynced'] as Map?)?.cast<String, dynamic>() ?? const {},
),
selfHosted: AcpBridgeServerSelfHostedConfig.fromJson(
(json['selfHosted'] as Map?)?.cast<String, dynamic>() ?? const {},
),
advancedOverrides: AcpBridgeServerAdvancedOverrides.fromJson(
(json['advancedOverrides'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
}
}
class AccountProfileResponse {
const AccountProfileResponse({
required this.profile,

View File

@ -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<String, dynamic>() ??
const {},
),
linuxDesktop: LinuxDesktopConfig.fromJson(
(json['linuxDesktop'] as Map?)?.cast<String, dynamic>() ?? 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<String> normalizeSavedGatewayTargets(Iterable<String> rawTargets) {

View File

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

View File

@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -21,7 +20,7 @@ Future<void> _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));
}

View File

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

View File

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

View File

@ -19,7 +19,7 @@ Future<void> _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));
}

View File

@ -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>[
ExternalAcpEndpointProfile.defaultsForProvider(
SingleAgentProvider.codex,
).copyWith(endpoint: 'https://agent.example.com'),
],
authorizedSkillDirectories: const <AuthorizedSkillDirectory>[
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',
);
});
}

View File

@ -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(<String, Object>{});
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',
);
});
}

View File

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