xworkmate-app/lib/features/settings/settings_page_support.dart
2026-04-09 07:15:12 +08:00

1081 lines
38 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/app_store_policy.dart';
import '../../app/ui_feature_manifest.dart';
import '../../app/workspace_navigation.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/gateway_runtime.dart';
import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart';
import 'codex_integration_card.dart';
import 'skill_directory_authorization_card.dart';
import '../../widgets/section_tabs.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
import 'settings_page_core.dart';
import 'settings_page_sections.dart';
import 'settings_page_gateway.dart';
import 'settings_page_gateway_connection.dart';
import 'settings_page_gateway_llm.dart';
import 'settings_page_presentation.dart';
import 'settings_page_multi_agent.dart';
import 'settings_page_device.dart';
import 'settings_page_widgets.dart';
extension SettingsPageSupportMixinInternal on SettingsPageStateInternal {
List<Widget> buildAboutInternal(
BuildContext context,
AppController controller,
) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('关于', 'About'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
InfoRowInternal(label: appText('应用', 'App'), value: kSystemAppName),
InfoRowInternal(
label: appText('版本', 'Version'),
value: controller.runtime.packageInfo.version,
),
InfoRowInternal(
label: appText('构建号', 'Build'),
value: controller.runtime.packageInfo.buildNumber,
),
InfoRowInternal(
label: appText('包名', 'Package'),
value: controller.runtime.packageInfo.packageName,
),
if (kAppStoreDistribution) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Text(
appText(
'当前构建启用了 App Store 分发策略Apple 渠道会隐藏实验入口,并禁用外部 CLI / 本地 Runtime 能力。',
'This build enables the App Store distribution policy: Apple storefront builds hide experimental surfaces and disable external CLI / local runtime capabilities.',
),
),
),
],
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('隐私政策', 'Privacy Policy'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
appText(
'说明本应用会保存哪些本地设置、哪些用户数据会按你的操作发送到外部网关或 LLM 端点,以及如何清除本地数据。',
'Explains which settings stay on-device, which user data is sent to your configured gateway or LLM endpoints, and how to clear local data.',
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
key: const ValueKey('settings-open-privacy-policy'),
onPressed: () => showPrivacyPolicyDialogInternal(context),
icon: const Icon(Icons.privacy_tip_outlined),
label: Text(appText('查看隐私政策', 'View Privacy Policy')),
),
],
),
),
];
}
Future<void> showPrivacyPolicyDialogInternal(BuildContext context) {
final theme = Theme.of(context);
return showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(appText('隐私政策', 'Privacy Policy')),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Text(
appText(privacyPolicyZhInternal, privacyPolicyEnInternal),
style: theme.textTheme.bodyMedium,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(appText('关闭', 'Close')),
),
],
);
},
);
}
static const String privacyPolicyZhInternal = '''
XWorkmate 隐私政策
1. 本地保存
- 应用会在本机保存你主动配置的工作区设置、界面偏好、线程草稿和诊断状态。
- 共享 Token、密码、API Key 等敏感信息使用系统安全存储;不会写入普通 SharedPreferences。
2. 发送到外部服务的数据
- 只有在你主动发起连接、发送消息、上传附件或测试连接时,应用才会把当前输入内容发送到你配置的 OpenClaw Gateway 或 LLM API Endpoint。
- 发送内容可能包括:提示词、会话上下文、你明确选择的附件路径与文件内容、以及完成请求所需的认证头。
3. 不会做的事情
- 不会接入广告 SDK不会做跨应用追踪不会在未操作时自动读取工作区文件。
- 不会把你的网关密码、共享 Token 或 LLM API Token 上传到本项目默认的开发者服务。
4. 第三方处理
- 你配置的 OpenClaw Gateway、LLM API Endpoint、对象存储或其它外部服务将按你自己的服务条款处理收到的数据。
- 你需要确认这些外部服务具备你要求的合规能力。
5. 删除与撤回
- 你可以在“设置 -> 诊断/集成”中清除本地线程、移除本地配置,并删除已保存的安全凭据。
- 如果你希望删除已经发送到外部服务的数据,需要在对应外部服务侧执行删除或撤回。
''';
static const String privacyPolicyEnInternal = '''
XWorkmate Privacy Policy
1. Local storage
- The app stores the settings, UI preferences, draft threads, and diagnostic state that you explicitly save on this device.
- Shared tokens, passwords, and API keys are stored in platform secure storage instead of plain SharedPreferences.
2. Data sent to external services
- Data is only sent when you explicitly connect, send a message, attach a file, or run a connection test against your configured OpenClaw Gateway or LLM API endpoint.
- Sent data can include prompts, conversation context, user-selected attachment paths and file contents, and the authentication headers required to complete the request.
3. What the app does not do
- It does not include advertising SDKs, cross-app tracking, or automatic workspace file reads without a user action.
- It does not upload your gateway passwords, shared tokens, or LLM API tokens to developer-operated services by default.
4. Third-party processing
- Your configured OpenClaw Gateway, LLM API endpoint, object storage, or other external services process the data you send under their own terms.
- You are responsible for confirming that those external services meet your compliance requirements.
5. Deletion and withdrawal
- You can clear local threads, remove local settings, and delete stored secrets from Settings.
- If you need data removed from an external service, you must request deletion from that external service directly.
''';
Future<void> saveSettingsInternal(
AppController controller,
SettingsSnapshot snapshot,
) {
return controller.saveSettingsDraft(snapshot);
}
Future<void> handleTopLevelApplyInternal(AppController controller) async {
await captureVisibleSecretDraftsInternal(controller);
await controller.applySettingsDraft();
if (!mounted) {
return;
}
setStateInternal(() {
resetSecureFieldUiAfterPersistInternal(controller);
});
}
Future<void> captureVisibleSecretDraftsInternal(
AppController controller,
) async {
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final gatewayToken = secretOverrideInternal(
gatewayTokenControllersInternal[index],
gatewayTokenStatesInternal[index],
);
if (gatewayToken.isNotEmpty) {
controller.saveGatewayTokenDraft(gatewayToken, profileIndex: index);
}
final gatewayPassword = secretOverrideInternal(
gatewayPasswordControllersInternal[index],
gatewayPasswordStatesInternal[index],
);
if (gatewayPassword.isNotEmpty) {
controller.saveGatewayPasswordDraft(
gatewayPassword,
profileIndex: index,
);
}
}
final aiGatewayApiKey = secretOverrideInternal(
aiGatewayApiKeyControllerInternal,
aiGatewayApiKeyStateInternal,
);
if (aiGatewayApiKey.isNotEmpty) {
controller.saveAiGatewayApiKeyDraft(aiGatewayApiKey);
}
final vaultToken = secretOverrideInternal(
vaultTokenControllerInternal,
vaultTokenStateInternal,
);
if (vaultToken.isNotEmpty) {
controller.saveVaultTokenDraft(vaultToken);
}
final ollamaApiKey = secretOverrideInternal(
ollamaApiKeyControllerInternal,
ollamaApiKeyStateInternal,
);
if (ollamaApiKey.isNotEmpty) {
controller.saveOllamaCloudApiKeyDraft(ollamaApiKey);
}
}
void resetSecureFieldUiAfterPersistInternal(AppController controller) {
final aiGatewayRef = controller.settings.aiGateway.apiKeyRef.trim().isEmpty
? 'ai_gateway_api_key'
: controller.settings.aiGateway.apiKeyRef.trim();
final vaultTokenRef = controller.settings.vault.tokenRef.trim().isEmpty
? 'vault_token'
: controller.settings.vault.tokenRef.trim();
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs[aiGatewayRef] != null ||
(aiGatewayRef == 'ai_gateway_api_key' &&
controller.settingsController.secureRefs['ai_gateway_api_key'] !=
null) ||
controller
.settingsController
.secureRefs[kAccountManagedSecretTargetAIGatewayAccessToken] !=
null;
final hasStoredVaultToken =
controller.settingsController.secureRefs[vaultTokenRef] != null ||
(vaultTokenRef == 'vault_token' &&
controller.settingsController.secureRefs['vault_token'] != null);
final hasStoredOllamaApiKey =
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
null;
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
gatewayTokenStatesInternal[index] = const SecretFieldUiStateInternal();
gatewayPasswordStatesInternal[index] = const SecretFieldUiStateInternal();
primeSecureFieldControllerInternal(
gatewayTokenControllersInternal[index],
hasStoredValue: controller.hasStoredGatewayTokenForProfile(index),
fieldState: gatewayTokenStatesInternal[index],
);
primeSecureFieldControllerInternal(
gatewayPasswordControllersInternal[index],
hasStoredValue: controller.hasStoredGatewayPasswordForProfile(index),
fieldState: gatewayPasswordStatesInternal[index],
);
}
aiGatewayApiKeyStateInternal = const SecretFieldUiStateInternal();
vaultTokenStateInternal = const SecretFieldUiStateInternal();
ollamaApiKeyStateInternal = const SecretFieldUiStateInternal();
primeSecureFieldControllerInternal(
aiGatewayApiKeyControllerInternal,
hasStoredValue: hasStoredAiGatewayApiKey,
fieldState: aiGatewayApiKeyStateInternal,
);
primeSecureFieldControllerInternal(
vaultTokenControllerInternal,
hasStoredValue: hasStoredVaultToken,
fieldState: vaultTokenStateInternal,
);
primeSecureFieldControllerInternal(
ollamaApiKeyControllerInternal,
hasStoredValue: hasStoredOllamaApiKey,
fieldState: ollamaApiKeyStateInternal,
);
}
void syncGatewayDraftControllersInternal(SettingsSnapshot settings) {
final current = selectedGatewayProfileInternal(settings);
syncDraftControllerValueInternal(
gatewaySetupCodeControllerInternal,
current.setupCode,
syncedValue: gatewaySetupCodeSyncedValueInternal,
onSyncedValueChanged: (value) =>
gatewaySetupCodeSyncedValueInternal = value,
);
syncDraftControllerValueInternal(
gatewayHostControllerInternal,
current.host,
syncedValue: gatewayHostSyncedValueInternal,
onSyncedValueChanged: (value) => gatewayHostSyncedValueInternal = value,
);
syncDraftControllerValueInternal(
gatewayPortControllerInternal,
'${current.port}',
syncedValue: gatewayPortSyncedValueInternal,
onSyncedValueChanged: (value) => gatewayPortSyncedValueInternal = value,
);
syncDraftControllerValueInternal(
gatewayTokenRefControllersInternal[selectedGatewayProfileIndexInternal],
current.tokenRef,
syncedValue:
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal],
onSyncedValueChanged: (value) =>
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
value,
);
syncDraftControllerValueInternal(
gatewayPasswordRefControllersInternal[selectedGatewayProfileIndexInternal],
current.passwordRef,
syncedValue:
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal],
onSyncedValueChanged: (value) =>
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
value,
);
}
void syncExternalAcpDraftControllersInternal(SettingsSnapshot settings) {
final activeKeys = settings.externalAcpEndpoints
.map((item) => item.providerKey)
.toSet();
for (final profile in settings.externalAcpEndpoints) {
final key = profile.providerKey;
final labelController = externalAcpLabelControllersInternal.putIfAbsent(
key,
() => TextEditingController(),
);
final endpointController = externalAcpEndpointControllersInternal
.putIfAbsent(key, () => TextEditingController());
final authController = externalAcpAuthControllersInternal.putIfAbsent(
key,
() => TextEditingController(),
);
syncDraftControllerValueInternal(
labelController,
profile.label,
syncedValue: externalAcpLabelSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpLabelSyncedValuesInternal[key] = value,
);
syncDraftControllerValueInternal(
endpointController,
profile.endpoint,
syncedValue: externalAcpEndpointSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpEndpointSyncedValuesInternal[key] = value,
);
syncDraftControllerValueInternal(
authController,
profile.authRef,
syncedValue: externalAcpAuthSyncedValuesInternal[key] ?? '',
onSyncedValueChanged: (value) =>
externalAcpAuthSyncedValuesInternal[key] = value,
);
}
disposeRemovedExternalAcpDraftsInternal(
externalAcpLabelControllersInternal,
activeKeys,
);
disposeRemovedExternalAcpDraftsInternal(
externalAcpEndpointControllersInternal,
activeKeys,
);
disposeRemovedExternalAcpDraftsInternal(
externalAcpAuthControllersInternal,
activeKeys,
);
externalAcpLabelSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpEndpointSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpAuthSyncedValuesInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpMessageByProviderInternal.removeWhere(
(key, _) => !activeKeys.contains(key),
);
externalAcpTestingProvidersInternal.removeWhere(
(key) => !activeKeys.contains(key),
);
}
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,
) {
final removedKeys = controllers.keys
.where((key) => !activeKeys.contains(key))
.toList(growable: false);
for (final key in removedKeys) {
controllers.remove(key)?.dispose();
}
}
GatewayConnectionProfile selectedGatewayProfileInternal(
SettingsSnapshot settings,
) {
final profiles = settings.gatewayProfiles;
final index = selectedGatewayProfileIndexInternal.clamp(
0,
profiles.length - 1,
);
return profiles[index];
}
RuntimeConnectionMode gatewayProfileModeForSlotInternal(
int index,
GatewayConnectionProfile profile,
) {
if (index == kGatewayLocalProfileIndex) {
return RuntimeConnectionMode.local;
}
if (index == kGatewayRemoteProfileIndex) {
return RuntimeConnectionMode.remote;
}
return switch (profile.mode) {
RuntimeConnectionMode.local => RuntimeConnectionMode.local,
RuntimeConnectionMode.remote => RuntimeConnectionMode.remote,
RuntimeConnectionMode.unconfigured =>
profile.host.trim().isNotEmpty || profile.setupCode.trim().isNotEmpty
? RuntimeConnectionMode.remote
: RuntimeConnectionMode.unconfigured,
};
}
String gatewayProfileSlotLabelInternal(int index) {
return switch (index) {
kGatewayLocalProfileIndex => appText(
'本地 OpenClaw Gateway',
'Local OpenClaw Gateway',
),
kGatewayRemoteProfileIndex => appText(
'远程 OpenClaw Gateway',
'Remote OpenClaw Gateway',
),
_ => appText(
'自定义连接源 ${index - kGatewayCustomProfileStartIndex + 1}',
'Custom source ${index - kGatewayCustomProfileStartIndex + 1}',
),
};
}
String gatewayProfileChipLabelInternal(
int index, {
required bool configured,
}) {
final label = switch (index) {
kGatewayLocalProfileIndex => gatewayProfileSlotLabelInternal(index),
kGatewayRemoteProfileIndex => gatewayProfileSlotLabelInternal(index),
_ => appText(
'连接源 ${index - kGatewayCustomProfileStartIndex + 1}',
'Source ${index - kGatewayCustomProfileStartIndex + 1}',
),
};
return appText(
configured ? label : '$label(空)',
configured ? label : '$label (empty)',
);
}
String gatewayProfileSlotDescriptionInternal(int index) {
return switch (index) {
kGatewayLocalProfileIndex => appText(
'固定本地连接源,默认 127.0.0.1:18789。这里只维护本地源参数不切换当前工作模式。',
'Fixed local source with default 127.0.0.1:18789. This card edits the local source only and does not switch the current work mode.',
),
kGatewayRemoteProfileIndex => appText(
'固定远程连接源,默认 openclaw.svc.plus:443。这里只维护远程源参数不切换当前工作模式。',
'Fixed remote source with default openclaw.svc.plus:443. This card edits the remote source only and does not switch the current work mode.',
),
_ => appText(
'预留自定义 OpenClaw 连接源槽位。当前版本先做配置存储,不绑定固定工作模式。',
'Reserved custom OpenClaw source slot. In this build it stores connection settings only and is not bound to a fixed work mode.',
),
};
}
GatewayConnectionProfile buildGatewayDraftProfileInternal(
SettingsSnapshot settings,
) {
final current = selectedGatewayProfileInternal(settings);
final mode = gatewayProfileModeForSlotInternal(
selectedGatewayProfileIndexInternal,
current,
);
final forceSetupCodeMode =
navigationContextInternal?.prefersGatewaySetupCode == true &&
detailInternal == SettingsDetailPage.gatewayConnection &&
selectedGatewayProfileIndexInternal != kGatewayLocalProfileIndex;
final useSetupCode = mode == RuntimeConnectionMode.local
? false
: forceSetupCodeMode || current.useSetupCode;
final tls = mode == RuntimeConnectionMode.local ? false : current.tls;
final parsedPort = int.tryParse(gatewayPortControllerInternal.text.trim());
final decoded = useSetupCode
? decodeGatewaySetupCode(gatewaySetupCodeControllerInternal.text)
: null;
final fallbackPort = switch (mode) {
RuntimeConnectionMode.local => 18789,
RuntimeConnectionMode.remote => tls ? 443 : current.port,
RuntimeConnectionMode.unconfigured => 443,
};
return current.copyWith(
mode: mode,
useSetupCode: useSetupCode,
setupCode: useSetupCode
? gatewaySetupCodeControllerInternal.text.trim()
: '',
host: useSetupCode
? (decoded?.host ?? current.host)
: gatewayHostControllerInternal.text.trim(),
port: useSetupCode
? (decoded?.port ?? current.port)
: (parsedPort ?? fallbackPort),
tls: useSetupCode ? (decoded?.tls ?? tls) : tls,
tokenRef:
gatewayTokenRefControllersInternal[selectedGatewayProfileIndexInternal]
.text
.trim(),
passwordRef:
gatewayPasswordRefControllersInternal[selectedGatewayProfileIndexInternal]
.text
.trim(),
);
}
Future<void> saveGatewayProfileInternal(
AppController controller,
SettingsSnapshot settings,
GatewayConnectionProfile profile,
) async {
final executionTarget =
selectedGatewayProfileIndexInternal == kGatewayLocalProfileIndex
? AssistantExecutionTarget.local
: AssistantExecutionTarget.remote;
final nextSettings = settings
.copyWithGatewayProfileAt(selectedGatewayProfileIndexInternal, profile)
.markGatewayTargetSaved(executionTarget);
await saveSettingsInternal(controller, nextSettings);
if (!mounted) {
return;
}
setStateInternal(() {
gatewaySetupCodeSyncedValueInternal = profile.setupCode;
gatewayHostSyncedValueInternal = profile.host;
gatewayPortSyncedValueInternal = '${profile.port}';
gatewayTokenRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
profile.tokenRef;
gatewayPasswordRefSyncedValuesInternal[selectedGatewayProfileIndexInternal] =
profile.passwordRef;
gatewayTestStateInternal = 'idle';
gatewayTestMessageInternal = '';
gatewayTestEndpointInternal = '';
});
}
Future<void> saveGatewayDraftInternal(
AppController controller,
SettingsSnapshot settings,
) async {
final profile = buildGatewayDraftProfileInternal(settings);
await saveGatewayProfileInternal(controller, settings, profile);
}
Future<void> saveGatewayAndApplyInternal(
AppController controller,
SettingsSnapshot settings,
) async {
await saveGatewayDraftInternal(controller, settings);
await handleTopLevelApplyInternal(controller);
}
Future<void> saveAiGatewayAndApplyInternal(
AppController controller,
SettingsSnapshot settings,
) async {
await saveAiGatewayDraftInternal(controller, settings);
await handleTopLevelApplyInternal(controller);
}
Future<void> saveMultiAgentConfigInternal(
AppController controller,
MultiAgentConfig config,
) {
return controller.saveSettingsDraft(
controller.settingsDraft.copyWith(multiAgent: config),
);
}
AiGatewayProfile buildAiGatewayDraftInternal(SettingsSnapshot settings) {
final draftName = aiGatewayNameControllerInternal.text.trim();
final draftBaseUrl = aiGatewayUrlControllerInternal.text.trim();
final draftApiKeyRef = aiGatewayApiKeyRefControllerInternal.text.trim();
final current = settings.aiGateway;
final defaults = AiGatewayProfile.defaults();
final connectionChanged =
draftBaseUrl != current.baseUrl || draftApiKeyRef != current.apiKeyRef;
return current.copyWith(
name: draftName,
baseUrl: draftBaseUrl,
apiKeyRef: draftApiKeyRef,
availableModels: connectionChanged
? defaults.availableModels
: current.availableModels,
selectedModels: connectionChanged
? defaults.selectedModels
: current.selectedModels,
syncState: connectionChanged ? defaults.syncState : current.syncState,
syncMessage: connectionChanged
? defaults.syncMessage
: current.syncMessage,
);
}
Future<void> saveAiGatewayDraftInternal(
AppController controller,
SettingsSnapshot settings,
) async {
final draft = buildAiGatewayDraftInternal(settings);
await saveSettingsInternal(controller, settings.copyWith(aiGateway: draft));
if (!mounted) {
return;
}
setStateInternal(() {
aiGatewayNameSyncedValueInternal = draft.name;
aiGatewayUrlSyncedValueInternal = draft.baseUrl;
aiGatewayApiKeyRefSyncedValueInternal = draft.apiKeyRef;
aiGatewayTestStateInternal = draft.syncState;
aiGatewayTestMessageInternal = '';
aiGatewayTestEndpointInternal = '';
});
}
Future<void> testAiGatewayConnectionInternal(
AppController controller,
SettingsSnapshot settings,
) async {
final messenger = ScaffoldMessenger.of(context);
final draft = buildAiGatewayDraftInternal(settings);
final apiKey = secretOverrideInternal(
aiGatewayApiKeyControllerInternal,
aiGatewayApiKeyStateInternal,
);
setStateInternal(() => aiGatewayTestingInternal = true);
try {
final result = await controller.settingsController
.testAiGatewayConnection(draft, apiKeyOverride: apiKey);
if (!mounted) {
return;
}
setStateInternal(() {
aiGatewayTestStateInternal = result.state;
aiGatewayTestMessageInternal = result.message;
aiGatewayTestEndpointInternal = result.endpoint;
});
messenger.showSnackBar(SnackBar(content: Text(result.message)));
} finally {
if (mounted) {
setStateInternal(() => aiGatewayTestingInternal = false);
}
}
}
Future<void> testVaultConnectionInternal(
AppController controller,
SettingsSnapshot settings,
) async {
final messenger = ScaffoldMessenger.of(context);
final token = secretOverrideInternal(
vaultTokenControllerInternal,
vaultTokenStateInternal,
);
final message = await controller.testVaultConnectionDraft(
snapshot: settings,
tokenOverride: token,
);
if (!mounted) {
return;
}
messenger.showSnackBar(SnackBar(content: Text(message)));
}
Future<void> testGatewayConnectionInternal(
AppController controller,
SettingsSnapshot settings,
) async {
final messenger = ScaffoldMessenger.of(context);
final gatewayDraft = buildGatewayDraftProfileInternal(settings);
final selectedProfileIndex = selectedGatewayProfileIndexInternal.clamp(
0,
settings.gatewayProfiles.length - 1,
);
final gatewayTokenController =
gatewayTokenControllersInternal[selectedProfileIndex];
final gatewayPasswordController =
gatewayPasswordControllersInternal[selectedProfileIndex];
final gatewayTokenState = gatewayTokenStatesInternal[selectedProfileIndex];
final gatewayPasswordState =
gatewayPasswordStatesInternal[selectedProfileIndex];
final executionTarget = switch (gatewayDraft.mode) {
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote,
};
var token = secretOverrideInternal(
gatewayTokenController,
gatewayTokenState,
);
var password = secretOverrideInternal(
gatewayPasswordController,
gatewayPasswordState,
);
if (token.isEmpty) {
token = await controller.settingsController.loadEffectiveGatewayToken(
profileIndex: selectedProfileIndex,
);
}
if (password.isEmpty) {
password = await controller.settingsController
.loadEffectiveGatewayPassword(profileIndex: selectedProfileIndex);
}
setStateInternal(() => gatewayTestingInternal = true);
try {
final result = await controller.testGatewayConnectionDraft(
profile: gatewayDraft,
executionTarget: executionTarget,
tokenOverride: token,
passwordOverride: password,
);
if (!mounted) {
return;
}
setStateInternal(() {
gatewayTestStateInternal = result.state;
gatewayTestMessageInternal = result.message;
gatewayTestEndpointInternal = result.endpoint;
});
messenger.showSnackBar(SnackBar(content: Text(result.message)));
} finally {
if (mounted) {
setStateInternal(() => gatewayTestingInternal = false);
}
}
}
Widget buildSettingsSectionActionsInternal({
required AppController controller,
required Key testKey,
required Key applyKey,
required Future<void> Function() onTest,
required Future<void> Function() onApply,
bool testing = false,
String? testLabel,
}) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: testKey,
onPressed: testing ? null : () => onTest(),
child: Text(
testing
? appText('测试中...', 'Testing...')
: (testLabel ?? appText('测试连接', 'Test Connection')),
),
),
FilledButton.tonal(
key: applyKey,
onPressed: () => onApply(),
child: Text(appText('保存并生效', 'Save & apply')),
),
],
);
}
List<String> filterAiGatewayModelsInternal(List<String> models) {
final query = aiGatewayModelSearchControllerInternal.text
.trim()
.toLowerCase();
if (query.isEmpty) {
return models;
}
return models
.where((modelId) => modelId.toLowerCase().contains(query))
.toList(growable: false);
}
Widget buildSecureFieldInternal({
Key? fieldKey,
required TextEditingController controller,
required String label,
required bool hasStoredValue,
required SecretFieldUiStateInternal fieldState,
required ValueChanged<SecretFieldUiStateInternal> onStateChanged,
required Future<String> Function() loadValue,
required Future<void> Function(String) onSubmitted,
required String storedHelperText,
required String emptyHelperText,
}) {
primeSecureFieldControllerInternal(
controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
);
final showMaskedPlaceholder =
hasStoredValue && !fieldState.showPlaintext && !fieldState.hasDraft;
return TextField(
key: fieldKey,
controller: controller,
obscureText: !fieldState.showPlaintext && fieldState.hasDraft,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: label,
helperText: hasStoredValue ? storedHelperText : emptyHelperText,
suffixIcon: fieldState.loading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
tooltip: fieldState.showPlaintext
? appText('隐藏', 'Hide')
: appText('查看', 'Reveal'),
onPressed: () => toggleSecureFieldVisibilityInternal(
controller: controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
onStateChanged: onStateChanged,
loadValue: loadValue,
),
icon: Icon(
fieldState.showPlaintext
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
),
),
onTap: () {
if (!showMaskedPlaceholder) {
return;
}
controller.clear();
onStateChanged(fieldState.copyWith(hasDraft: true));
},
onChanged: (value) {
if (value == storedSecretMaskInternal) {
return;
}
final nextHasDraft = value.trim().isNotEmpty;
if (nextHasDraft == fieldState.hasDraft) {
return;
}
onStateChanged(fieldState.copyWith(hasDraft: nextHasDraft));
},
onSubmitted: (_) => persistSecureFieldIfNeededInternal(
controller: controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
onStateChanged: onStateChanged,
onSubmitted: onSubmitted,
),
);
}
Future<void> toggleSecureFieldVisibilityInternal({
required TextEditingController controller,
required bool hasStoredValue,
required SecretFieldUiStateInternal fieldState,
required ValueChanged<SecretFieldUiStateInternal> onStateChanged,
required Future<String> Function() loadValue,
}) async {
if (fieldState.showPlaintext) {
if (fieldState.hasDraft) {
onStateChanged(fieldState.copyWith(showPlaintext: false));
return;
}
if (hasStoredValue) {
syncControllerValueInternal(controller, storedSecretMaskInternal);
} else {
controller.clear();
}
onStateChanged(const SecretFieldUiStateInternal());
return;
}
if (fieldState.hasDraft || !hasStoredValue) {
onStateChanged(fieldState.copyWith(showPlaintext: true, loading: false));
return;
}
onStateChanged(fieldState.copyWith(loading: true));
final value = (await loadValue()).trim();
if (!mounted) {
return;
}
if (value.isNotEmpty) {
syncControllerValueInternal(controller, value);
} else {
controller.clear();
}
onStateChanged(
const SecretFieldUiStateInternal(showPlaintext: true, hasDraft: false),
);
}
Future<void> persistSecureFieldIfNeededInternal({
required TextEditingController controller,
required bool hasStoredValue,
required SecretFieldUiStateInternal fieldState,
required ValueChanged<SecretFieldUiStateInternal> onStateChanged,
required Future<void> Function(String) onSubmitted,
}) async {
final value = normalizeSecretValueInternal(controller.text);
if (value.isEmpty) {
return;
}
if (!fieldState.hasDraft && hasStoredValue) {
return;
}
await onSubmitted(value);
if (!mounted) {
return;
}
syncControllerValueInternal(controller, storedSecretMaskInternal);
onStateChanged(const SecretFieldUiStateInternal());
}
void primeSecureFieldControllerInternal(
TextEditingController controller, {
required bool hasStoredValue,
required SecretFieldUiStateInternal fieldState,
}) {
if (fieldState.showPlaintext || fieldState.hasDraft) {
return;
}
final nextValue = hasStoredValue ? storedSecretMaskInternal : '';
if (controller.text == nextValue) {
return;
}
syncControllerValueInternal(controller, nextValue);
}
String secretOverrideInternal(
TextEditingController controller,
SecretFieldUiStateInternal fieldState,
) {
if (!fieldState.showPlaintext && !fieldState.hasDraft) {
return '';
}
return normalizeSecretValueInternal(controller.text);
}
String normalizeSecretValueInternal(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == storedSecretMaskInternal) {
return '';
}
return trimmed;
}
AiGatewayFeedbackThemeInternal aiGatewayFeedbackThemeInternal(
BuildContext context,
String state,
) {
final colorScheme = Theme.of(context).colorScheme;
return switch (state) {
'ready' => AiGatewayFeedbackThemeInternal(
background: colorScheme.primaryContainer,
border: colorScheme.primary,
foreground: colorScheme.onPrimaryContainer,
),
'empty' => AiGatewayFeedbackThemeInternal(
background: colorScheme.secondaryContainer,
border: colorScheme.secondary,
foreground: colorScheme.onSecondaryContainer,
),
'error' || 'invalid' => AiGatewayFeedbackThemeInternal(
background: colorScheme.errorContainer,
border: colorScheme.error,
foreground: colorScheme.onErrorContainer,
),
_ => AiGatewayFeedbackThemeInternal(
background: colorScheme.surfaceContainerHighest,
border: colorScheme.outlineVariant,
foreground: colorScheme.onSurfaceVariant,
),
};
}
void syncControllerValueInternal(
TextEditingController controller,
String value,
) {
if (controller.text == value) {
return;
}
controller.value = controller.value.copyWith(
text: value,
selection: TextSelection.collapsed(offset: value.length),
composing: TextRange.empty,
);
}
void syncDraftControllerValueInternal(
TextEditingController controller,
String value, {
required String syncedValue,
required ValueChanged<String> onSyncedValueChanged,
}) {
final hasLocalDraft = controller.text != syncedValue;
if (hasLocalDraft && controller.text != value) {
return;
}
syncControllerValueInternal(controller, value);
if (syncedValue != value) {
onSyncedValueChanged(value);
}
}
bool matchesRuntimeLogFilterInternal(RuntimeLogEntry entry) {
final query = runtimeLogFilterControllerInternal.text.trim().toLowerCase();
if (query.isEmpty) {
return true;
}
final haystack = '${entry.level} ${entry.category} ${entry.message}'
.toLowerCase();
return haystack.contains(query);
}
}