xworkmate-app/lib/web/web_settings_page.dart
2026-03-26 17:55:38 +08:00

1592 lines
60 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.

import 'dart:async';
import 'package:flutter/material.dart';
import '../app/app_controller_web.dart';
import '../app/app_metadata.dart';
import '../app/ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/runtime_models.dart';
import '../theme/app_palette.dart';
import '../widgets/desktop_workspace_scaffold.dart';
import '../widgets/section_tabs.dart';
import '../widgets/surface_card.dart';
import '../widgets/top_bar.dart';
class WebSettingsPage extends StatefulWidget {
const WebSettingsPage({super.key, required this.controller});
final AppController controller;
@override
State<WebSettingsPage> createState() => _WebSettingsPageState();
}
enum _WebGatewaySettingsSubTab { gateway, llm, acp }
class _WebSettingsPageState extends State<WebSettingsPage> {
late final TextEditingController _directNameController;
late final TextEditingController _directBaseUrlController;
late final TextEditingController _directProviderController;
late final TextEditingController _directApiKeyController;
late final TextEditingController _localHostController;
late final TextEditingController _localPortController;
late final TextEditingController _localTokenController;
late final TextEditingController _localPasswordController;
late final TextEditingController _remoteHostController;
late final TextEditingController _remotePortController;
late final TextEditingController _remoteTokenController;
late final TextEditingController _remotePasswordController;
late final TextEditingController _sessionRemoteBaseUrlController;
late final TextEditingController _sessionApiTokenController;
late final Map<String, TextEditingController> _externalAcpLabelControllers;
late final Map<String, TextEditingController> _externalAcpEndpointControllers;
late WebSessionPersistenceMode _sessionPersistenceMode;
bool _remoteTls = true;
_WebGatewaySettingsSubTab _gatewaySubTab = _WebGatewaySettingsSubTab.gateway;
String _directMessage = '';
String _localGatewayMessage = '';
String _remoteGatewayMessage = '';
String _sessionPersistenceMessage = '';
@override
void initState() {
super.initState();
_directNameController = TextEditingController();
_directBaseUrlController = TextEditingController();
_directProviderController = TextEditingController();
_directApiKeyController = TextEditingController();
_localHostController = TextEditingController();
_localPortController = TextEditingController();
_localTokenController = TextEditingController();
_localPasswordController = TextEditingController();
_remoteHostController = TextEditingController();
_remotePortController = TextEditingController();
_remoteTokenController = TextEditingController();
_remotePasswordController = TextEditingController();
_sessionRemoteBaseUrlController = TextEditingController();
_sessionApiTokenController = TextEditingController();
_externalAcpLabelControllers = <String, TextEditingController>{};
_externalAcpEndpointControllers = <String, TextEditingController>{};
_sessionPersistenceMode = widget.controller.webSessionPersistence.mode;
_syncControllers();
}
@override
void didUpdateWidget(covariant WebSettingsPage oldWidget) {
super.didUpdateWidget(oldWidget);
_syncControllers();
}
@override
void dispose() {
_directNameController.dispose();
_directBaseUrlController.dispose();
_directProviderController.dispose();
_directApiKeyController.dispose();
_localHostController.dispose();
_localPortController.dispose();
_localTokenController.dispose();
_localPasswordController.dispose();
_remoteHostController.dispose();
_remotePortController.dispose();
_remoteTokenController.dispose();
_remotePasswordController.dispose();
_sessionRemoteBaseUrlController.dispose();
_sessionApiTokenController.dispose();
for (final controller in _externalAcpLabelControllers.values) {
controller.dispose();
}
for (final controller in _externalAcpEndpointControllers.values) {
controller.dispose();
}
super.dispose();
}
void _syncControllers() {
final settings = widget.controller.settingsDraft;
final localProfile = settings.primaryLocalGatewayProfile;
final remoteProfile = settings.primaryRemoteGatewayProfile;
_setIfDifferent(_directNameController, settings.aiGateway.name);
_setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl);
_setIfDifferent(_directProviderController, settings.defaultProvider);
_setIfDifferent(
_directApiKeyController,
widget.controller.storedAiGatewayApiKeyMask == null
? ''
: _directApiKeyController.text,
);
_setIfDifferent(_localHostController, localProfile.host);
_setIfDifferent(_localPortController, '${localProfile.port}');
_setIfDifferent(_remoteHostController, remoteProfile.host);
_setIfDifferent(_remotePortController, '${remoteProfile.port}');
_remoteTls = remoteProfile.tls;
_setIfDifferent(
_localTokenController,
widget.controller.storedRelayTokenMaskForProfile(
kGatewayLocalProfileIndex,
) ==
null
? ''
: _localTokenController.text,
);
_setIfDifferent(
_localPasswordController,
widget.controller.storedRelayPasswordMaskForProfile(
kGatewayLocalProfileIndex,
) ==
null
? ''
: _localPasswordController.text,
);
_setIfDifferent(
_remoteTokenController,
widget.controller.storedRelayTokenMaskForProfile(
kGatewayRemoteProfileIndex,
) ==
null
? ''
: _remoteTokenController.text,
);
_setIfDifferent(
_remotePasswordController,
widget.controller.storedRelayPasswordMaskForProfile(
kGatewayRemoteProfileIndex,
) ==
null
? ''
: _remotePasswordController.text,
);
_sessionPersistenceMode = settings.webSessionPersistence.mode;
_setIfDifferent(
_sessionRemoteBaseUrlController,
settings.webSessionPersistence.remoteBaseUrl,
);
_setIfDifferent(
_sessionApiTokenController,
widget.controller.storedWebSessionApiTokenMask == null
? ''
: _sessionApiTokenController.text,
);
_syncExternalAcpControllers(settings);
}
void _syncExternalAcpControllers(SettingsSnapshot settings) {
final activeKeys = settings.externalAcpEndpoints
.map((item) => item.providerKey)
.toSet();
for (final profile in settings.externalAcpEndpoints) {
final key = profile.providerKey;
final labelController = _externalAcpLabelControllers.putIfAbsent(
key,
() => TextEditingController(),
);
final endpointController = _externalAcpEndpointControllers.putIfAbsent(
key,
() => TextEditingController(),
);
_setIfDifferent(labelController, profile.label);
_setIfDifferent(endpointController, profile.endpoint);
}
_disposeRemovedControllers(_externalAcpLabelControllers, activeKeys);
_disposeRemovedControllers(_externalAcpEndpointControllers, activeKeys);
}
void _disposeRemovedControllers(
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();
}
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
final availableTabs = uiFeatures.availableSettingsTabs;
final currentTab = uiFeatures.sanitizeSettingsTab(
controller.settingsTab,
);
final showGlobalApplyBar =
currentTab != SettingsTab.gateway ||
_gatewaySubTab == _WebGatewaySettingsSubTab.acp;
return DesktopWorkspaceScaffold(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: <AppBreadcrumbItem>[
AppBreadcrumbItem(
label: appText('主页', 'Home'),
icon: Icons.home_rounded,
onTap: controller.navigateHome,
),
AppBreadcrumbItem(
label: appText('设置', 'Settings'),
onTap: () => controller.openSettings(tab: currentTab),
),
AppBreadcrumbItem(label: currentTab.label),
],
title: appText('设置', 'Settings'),
subtitle: appText(
'配置 XWorkmate Web 工作区、网关默认项、界面与诊断选项',
'Configure workspace, gateway defaults, appearance, and diagnostics for XWorkmate Web.',
),
trailing: SizedBox(
width: 260,
child: TextField(
key: const ValueKey('web-settings-search-field'),
decoration: InputDecoration(
hintText: appText('搜索设置', 'Search settings'),
prefixIcon: const Icon(Icons.search_rounded),
),
),
),
),
const SizedBox(height: 24),
if (showGlobalApplyBar) ...[
_buildGlobalApplyBar(context, controller),
const SizedBox(height: 16),
],
SectionTabs(
items: availableTabs.map((item) => item.label).toList(),
value: currentTab.label,
onChanged: (label) {
final tab = availableTabs.firstWhere(
(item) => item.label == label,
);
controller.setSettingsTab(tab);
},
),
const SizedBox(height: 24),
...switch (currentTab) {
SettingsTab.general => _buildGeneral(
context,
controller,
controller.settingsDraft,
),
SettingsTab.gateway => _buildGateway(
context,
controller,
controller.settingsDraft,
),
SettingsTab.appearance => _buildAppearance(
context,
controller,
),
_ => _buildAbout(context),
},
],
),
),
);
},
);
}
Widget _buildGlobalApplyBar(BuildContext context, AppController controller) {
final theme = Theme.of(context);
final hasDraft = controller.hasSettingsDraftChanges;
final hasPendingApply = controller.hasPendingSettingsApply;
final message = controller.settingsDraftStatusMessage;
return SurfaceCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设置提交流程', 'Settings Submission'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
message.isNotEmpty
? message
: hasDraft
? appText(
'当前存在未保存草稿。保存:仅保存配置,不立即生效。',
'There are unsaved drafts. Save persists configuration only and does not apply it immediately.',
)
: hasPendingApply
? appText(
'当前存在已保存但未应用的更改。应用:立即按当前配置生效。',
'There are saved changes waiting to be applied. Apply makes the current configuration take effect immediately.',
)
: appText(
'当前没有待提交更改。',
'There are no pending settings changes.',
),
),
],
),
),
const SizedBox(width: 16),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
key: const ValueKey('settings-global-save-button'),
onPressed:
hasDraft || _gatewaySubTab == _WebGatewaySettingsSubTab.acp
? () => _handleTopLevelSave(controller)
: null,
child: Text(appText('保存', 'Save')),
),
FilledButton.tonal(
key: const ValueKey('settings-global-apply-button'),
onPressed:
(hasDraft ||
hasPendingApply ||
_gatewaySubTab == _WebGatewaySettingsSubTab.acp)
? () => _handleTopLevelApply(controller)
: null,
child: Text(appText('应用', 'Apply')),
),
],
),
],
),
);
}
List<Widget> _buildGeneral(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final targets = controller
.featuresFor(UiFeaturePlatform.web)
.availableExecutionTargets
.toList(growable: false);
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('通用', 'General'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'这里维护 Web 默认执行目标与会话持久化摘要,结构与 App 设置页保持一致。',
'Maintain the default web execution target and session persistence summary here, aligned with the app settings layout.',
),
),
const SizedBox(height: 16),
Text(
appText('默认工作模式', 'Default work mode'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
DropdownButtonFormField<AssistantExecutionTarget>(
initialValue: settings.assistantExecutionTarget,
items: targets
.map((target) {
return DropdownMenuItem<AssistantExecutionTarget>(
value: target,
child: Text(_targetLabel(target)),
);
})
.toList(growable: false),
onChanged: (value) {
if (value != null) {
unawaited(
controller.saveSettingsDraft(
settings.copyWith(assistantExecutionTarget: value),
),
);
}
},
),
const SizedBox(height: 12),
Text(controller.conversationPersistenceSummary),
],
),
),
];
}
List<Widget> _buildGateway(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
return [
SectionTabs(
items: <String>[
'OpenClaw Gateway',
appText('LLM 接入点', 'LLM Endpoints'),
appText('ACP 外部接入', 'External ACP'),
],
value: switch (_gatewaySubTab) {
_WebGatewaySettingsSubTab.gateway => 'OpenClaw Gateway',
_WebGatewaySettingsSubTab.llm => appText('LLM 接入点', 'LLM Endpoints'),
_WebGatewaySettingsSubTab.acp => appText('ACP 外部接入', 'External ACP'),
},
onChanged: (value) => setState(() {
_gatewaySubTab = switch (value) {
'OpenClaw Gateway' => _WebGatewaySettingsSubTab.gateway,
_ when value == appText('LLM 接入点', 'LLM Endpoints') =>
_WebGatewaySettingsSubTab.llm,
_ => _WebGatewaySettingsSubTab.acp,
};
}),
),
const SizedBox(height: 16),
...switch (_gatewaySubTab) {
_WebGatewaySettingsSubTab.gateway => _buildGatewayOverview(
context,
controller,
),
_WebGatewaySettingsSubTab.llm => _buildLlmEndpointManager(
context,
controller,
settings,
),
_WebGatewaySettingsSubTab.acp => <Widget>[
_buildExternalAcpEndpointManager(context, controller),
],
},
];
}
List<Widget> _buildGatewayOverview(
BuildContext context,
AppController controller,
) {
final palette = context.palette;
return [
SurfaceCard(
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: palette.warning),
const SizedBox(width: 12),
Expanded(
child: Text(
appText(
'Web 版凭证会保存在当前浏览器本地存储中,安全性低于桌面端安全存储。请仅在可信设备上使用。',
'Web credentials are persisted in this browser and are less secure than desktop secure storage. Use only on trusted devices.',
),
),
),
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OpenClaw Gateway',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'这里维护 Local / Remote Gateway 与浏览器会话持久化配置。保存:仅保存配置,不立即生效。应用:立即按当前配置生效。',
'Maintain Local / Remote Gateway and browser session persistence here. Save persists configuration only, while Apply makes it take effect immediately.',
),
),
],
),
),
const SizedBox(height: 16),
_buildGatewayCard(
context,
controller: controller,
title: appText('Local Gateway', 'Local Gateway'),
executionTarget: AssistantExecutionTarget.local,
profileIndex: kGatewayLocalProfileIndex,
hostController: _localHostController,
portController: _localPortController,
tokenController: _localTokenController,
passwordController: _localPasswordController,
tokenMask: controller.storedRelayTokenMaskForProfile(
kGatewayLocalProfileIndex,
),
passwordMask: controller.storedRelayPasswordMaskForProfile(
kGatewayLocalProfileIndex,
),
tls: false,
onTlsChanged: null,
message: _localGatewayMessage,
onMessageChanged: (value) {
setState(() => _localGatewayMessage = value);
},
),
const SizedBox(height: 12),
_buildGatewayCard(
context,
controller: controller,
title: appText('Remote Gateway', 'Remote Gateway'),
executionTarget: AssistantExecutionTarget.remote,
profileIndex: kGatewayRemoteProfileIndex,
hostController: _remoteHostController,
portController: _remotePortController,
tokenController: _remoteTokenController,
passwordController: _remotePasswordController,
tokenMask: controller.storedRelayTokenMaskForProfile(
kGatewayRemoteProfileIndex,
),
passwordMask: controller.storedRelayPasswordMaskForProfile(
kGatewayRemoteProfileIndex,
),
tls: _remoteTls,
onTlsChanged: (value) {
setState(() => _remoteTls = value);
},
message: _remoteGatewayMessage,
onMessageChanged: (value) {
setState(() => _remoteGatewayMessage = value);
},
),
const SizedBox(height: 12),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('会话持久化', 'Session persistence'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
appText(
'默认使用浏览器本地缓存保存 Assistant 会话。若要做 durable store请配置一个 HTTPS Session API该 API 可以由 PostgreSQL 等后端数据库承接,但浏览器不会直接连接数据库。',
'Assistant sessions default to browser-local cache. For durable storage, configure an HTTPS session API. That API can be backed by PostgreSQL, but the browser never connects to the database directly.',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<WebSessionPersistenceMode>(
initialValue: _sessionPersistenceMode,
items: WebSessionPersistenceMode.values
.map(
(mode) => DropdownMenuItem<WebSessionPersistenceMode>(
value: mode,
child: Text(mode.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_sessionPersistenceMode = value;
});
},
decoration: InputDecoration(
labelText: appText('保存位置', 'Persistence target'),
),
),
if (_sessionPersistenceMode ==
WebSessionPersistenceMode.remote) ...[
const SizedBox(height: 10),
TextField(
controller: _sessionRemoteBaseUrlController,
decoration: InputDecoration(
labelText: appText(
'Session API Base URL',
'Session API Base URL',
),
hintText: 'https://xworkmate.svc.plus/api/web-sessions',
),
),
const SizedBox(height: 10),
TextField(
controller: _sessionApiTokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Session API Token', 'Session API token'),
helperText: controller.storedWebSessionApiTokenMask == null
? appText(
'只保留在当前浏览器会话内存中;刷新页面后需要重新输入。',
'Kept only in the current browser session memory; re-enter it after reload.',
)
: '${appText('当前会话', 'This session')}: ${controller.storedWebSessionApiTokenMask} · ${appText('刷新后需重新输入', 'Re-enter after reload')}',
),
),
],
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton(
onPressed: () async {
await controller.saveWebSessionPersistenceConfiguration(
mode: _sessionPersistenceMode,
remoteBaseUrl: _sessionRemoteBaseUrlController.text,
apiToken: _sessionApiTokenController.text,
);
if (!mounted) {
return;
}
setState(() {
_sessionPersistenceMessage =
controller.sessionPersistenceStatusMessage;
});
},
child: Text(appText('Save', 'Save')),
),
FilledButton.tonal(
onPressed: () async {
await controller.saveWebSessionPersistenceConfiguration(
mode: _sessionPersistenceMode,
remoteBaseUrl: _sessionRemoteBaseUrlController.text,
apiToken: _sessionApiTokenController.text,
);
if (!mounted) {
return;
}
setState(() {
_sessionPersistenceMessage = appText(
'会话存储配置已应用到当前浏览器会话。',
'Session persistence settings are now applied to this browser session.',
);
});
},
child: Text(appText('Apply', 'Apply')),
),
],
),
if (_sessionPersistenceMessage.trim().isNotEmpty ||
controller.sessionPersistenceStatusMessage
.trim()
.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
(_sessionPersistenceMessage.trim().isNotEmpty
? _sessionPersistenceMessage
: controller.sessionPersistenceStatusMessage)
.trim(),
),
],
],
),
),
];
}
List<Widget> _buildLlmEndpointManager(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final palette = context.palette;
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('LLM 接入点', 'LLM Endpoints'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'Web 版保持与 App 一致的接入点结构,但当前仅开放主 LLM API 连接源。',
'Web keeps the same endpoint structure as the app, but currently exposes only the primary LLM API source.',
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ChoiceChip(
key: const ValueKey('web-settings-llm-primary-chip'),
selected: true,
avatar: const Icon(Icons.link_rounded, size: 18),
label: Text(appText('主 LLM API', 'Primary LLM API')),
onSelected: (_) {},
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('连接源详情', 'Source details'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextField(
controller: _directNameController,
decoration: InputDecoration(
labelText: appText('配置名称', 'Profile name'),
),
),
const SizedBox(height: 10),
TextField(
controller: _directBaseUrlController,
decoration: InputDecoration(
labelText: appText(
'LLM API Endpoint',
'LLM API Endpoint',
),
hintText: 'https://api.example.com/v1',
),
),
const SizedBox(height: 10),
TextField(
controller: _directProviderController,
decoration: InputDecoration(
labelText: appText(
'LLM API Token 引用',
'LLM API token reference',
),
),
),
const SizedBox(height: 10),
TextField(
controller: _directApiKeyController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('LLM API Token', 'LLM API Token'),
helperText: controller.storedAiGatewayApiKeyMask == null
? null
: '${appText('已安全保存', 'Stored securely')}: ${controller.storedAiGatewayApiKeyMask}',
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: controller.resolvedAiGatewayModel.isEmpty
? null
: controller.resolvedAiGatewayModel,
items: settings.aiGateway.availableModels
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
),
)
.toList(growable: false),
onChanged: (value) {
if (value != null) {
controller.selectDirectModel(value);
}
},
decoration: InputDecoration(
labelText: appText('默认模型', 'Default model'),
hintText: appText('先同步模型目录', 'Sync model catalog first'),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
onPressed: controller.aiGatewayBusy
? null
: () async {
final result = await controller
.testAiGatewayConnection(
baseUrl: _directBaseUrlController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() => _directMessage = result.message);
},
child: Text(appText('测试连接', 'Test')),
),
FilledButton(
onPressed: controller.aiGatewayBusy
? null
: () async {
await controller.saveAiGatewayConfiguration(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
defaultModel:
controller.resolvedAiGatewayModel,
);
if (!mounted) {
return;
}
setState(() {
_directMessage = appText(
'配置已保存,尚未同步模型目录。',
'Configuration saved; model catalog not synced yet.',
);
});
},
child: Text(appText('保存', 'Save')),
),
FilledButton.icon(
onPressed: controller.aiGatewayBusy
? null
: () async {
await controller.saveAiGatewayConfiguration(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
defaultModel:
controller.resolvedAiGatewayModel,
);
try {
await controller.syncAiGatewayModels(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() {
_directMessage = controller
.settings
.aiGateway
.syncMessage;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() => _directMessage = '$error');
}
},
icon: controller.aiGatewayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.play_circle_outline_rounded),
label: Text(appText('应用', 'Apply')),
),
],
),
if (_directMessage.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
_directMessage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
],
],
),
),
],
),
),
];
}
Widget _buildExternalAcpEndpointManager(
BuildContext context,
AppController controller,
) {
final theme = Theme.of(context);
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('外部 ACP Server Endpoint', 'External ACP Server Endpoints'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。',
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
key: const ValueKey('web-external-acp-provider-add-button'),
onPressed: () =>
_showAddExternalAcpProviderWizard(context, controller),
icon: const Icon(Icons.add_rounded),
label: Text(
appText('添加更多自定义配置', 'Add more custom configurations'),
),
),
),
const SizedBox(height: 16),
...controller.settingsDraft.externalAcpEndpoints.map(
(profile) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildExternalAcpProviderCard(
context,
controller,
profile,
),
),
),
],
),
);
}
Widget _buildExternalAcpProviderCard(
BuildContext context,
AppController controller,
ExternalAcpEndpointProfile profile,
) {
final provider = profile.toProvider();
final labelController = _externalAcpLabelControllers[profile.providerKey]!;
final endpointController =
_externalAcpEndpointControllers[profile.providerKey]!;
final configured = endpointController.text.trim().isNotEmpty;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
provider.label,
style: Theme.of(context).textTheme.titleMedium,
),
),
if (!profile.isPreset) ...[
IconButton(
tooltip: appText('删除 Provider', 'Remove provider'),
onPressed: () {
final next = controller.settingsDraft.copyWith(
externalAcpEndpoints: controller
.settingsDraft
.externalAcpEndpoints
.where(
(item) => item.providerKey != profile.providerKey,
)
.toList(growable: false),
);
unawaited(controller.saveSettingsDraft(next));
},
icon: const Icon(Icons.delete_outline_rounded),
),
const SizedBox(width: 4),
],
_StatusChip(
label: configured
? appText('已配置', 'Configured')
: appText('未配置', 'Empty'),
tone: configured ? _StatusChipTone.ready : _StatusChipTone.idle,
),
],
),
const SizedBox(height: 12),
TextField(
controller: labelController,
decoration: InputDecoration(
labelText: appText('显示名称', 'Display name'),
),
onChanged: (_) => _stageExternalAcpDraft(controller),
),
const SizedBox(height: 12),
TextField(
controller: endpointController,
decoration: InputDecoration(
labelText: appText('ACP Server Endpoint', 'ACP Server Endpoint'),
),
onChanged: (_) => _stageExternalAcpDraft(controller),
),
const SizedBox(height: 8),
Text(
appText(
'示例ws://127.0.0.1:9001、wss://acp.example.com/rpc、http://127.0.0.1:8080、https://agent.example.com',
'Examples: ws://127.0.0.1:9001, wss://acp.example.com/rpc, http://127.0.0.1:8080, https://agent.example.com',
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
Future<void> _handleTopLevelSave(AppController controller) async {
_stageExternalAcpDraft(controller);
await controller.persistSettingsDraft();
}
Future<void> _handleTopLevelApply(AppController controller) async {
_stageExternalAcpDraft(controller);
await controller.applySettingsDraft();
}
void _stageExternalAcpDraft(AppController controller) {
final nextProfiles = controller.settingsDraft.externalAcpEndpoints
.map(
(profile) => profile.copyWith(
label:
_externalAcpLabelControllers[profile.providerKey]?.text ??
profile.label,
endpoint:
_externalAcpEndpointControllers[profile.providerKey]?.text ??
profile.endpoint,
),
)
.toList(growable: false);
final next = controller.settingsDraft.copyWith(
externalAcpEndpoints: nextProfiles,
);
if (next.toJsonString() == controller.settingsDraft.toJsonString()) {
return;
}
unawaited(controller.saveSettingsDraft(next));
}
Future<void> _showAddExternalAcpProviderWizard(
BuildContext context,
AppController controller,
) async {
final settings = controller.settingsDraft;
final nameController = TextEditingController();
final endpointController = TextEditingController();
var attemptedSubmit = false;
try {
final profile = await showDialog<ExternalAcpEndpointProfile>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setDialogState) {
final name = nameController.text.trim();
final endpoint = endpointController.text.trim();
final endpointValid =
endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint);
final canSubmit =
name.isNotEmpty && endpoint.isNotEmpty && endpointValid;
return AlertDialog(
title: Text(
appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'),
),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'通过向导新增更多外部 Agent Provider。先填写显示名称再输入可访问的 ACP Server Endpoint。',
'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.',
),
),
const SizedBox(height: 16),
Text(
appText('步骤 1 · 显示名称', 'Step 1 · Display name'),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey(
'web-external-acp-wizard-name-field',
),
controller: nameController,
autofocus: true,
decoration: InputDecoration(
hintText: appText(
'例如Claude Sonnet / Lab Agent',
'For example: Claude Sonnet / Lab Agent',
),
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 16),
Text(
appText(
'步骤 2 · ACP Server Endpoint',
'Step 2 · ACP Server Endpoint',
),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextField(
key: const ValueKey(
'web-external-acp-wizard-endpoint-field',
),
controller: endpointController,
decoration: InputDecoration(
hintText: 'ws://127.0.0.1:9001',
errorText: attemptedSubmit && endpoint.isEmpty
? appText(
'请输入 ACP Server Endpoint。',
'Enter an ACP server endpoint.',
)
: attemptedSubmit && !endpointValid
? appText(
'仅支持 ws / wss / http / https。',
'Only ws / wss / http / https are supported.',
)
: null,
),
onChanged: (_) => setDialogState(() {}),
),
const SizedBox(height: 8),
Text(
appText(
'支持协议ws、wss、http、https。新增后会出现在下方列表并和助手页的 provider 菜单保持一致。',
'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.',
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
key: const ValueKey(
'web-external-acp-wizard-confirm-button',
),
onPressed: canSubmit
? () {
Navigator.of(dialogContext).pop(
buildCustomExternalAcpEndpointProfile(
settings.externalAcpEndpoints,
label: name,
endpoint: endpoint,
),
);
}
: () {
setDialogState(() {
attemptedSubmit = true;
});
},
child: Text(appText('添加', 'Add')),
),
],
);
},
);
},
);
if (profile == null) {
return;
}
await controller.saveSettingsDraft(
settings.copyWith(
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
...settings.externalAcpEndpoints,
profile,
],
),
);
} finally {
nameController.dispose();
endpointController.dispose();
}
}
Widget _buildGatewayCard(
BuildContext context, {
required AppController controller,
required String title,
required AssistantExecutionTarget executionTarget,
required int profileIndex,
required TextEditingController hostController,
required TextEditingController portController,
required TextEditingController tokenController,
required TextEditingController passwordController,
required String? tokenMask,
required String? passwordMask,
required bool tls,
required ValueChanged<bool>? onTlsChanged,
required String message,
required ValueChanged<String> onMessageChanged,
}) {
final expectedMode = executionTarget == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final matchesTarget = controller.connection.mode == expectedMode;
final status = matchesTarget
? controller.connection.status.label
: RuntimeConnectionStatus.offline.label;
final endpoint =
'${hostController.text.trim()}:${_parsePort(portController.text, fallback: 443)}';
final statusEndpoint = matchesTarget
? (controller.connection.remoteAddress?.trim().isNotEmpty == true
? controller.connection.remoteAddress!.trim()
: endpoint)
: endpoint;
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
TextField(
controller: hostController,
decoration: InputDecoration(
labelText: appText('主机或 URL', 'Host or URL'),
),
),
const SizedBox(height: 10),
TextField(
controller: portController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: appText('端口', 'Port')),
),
const SizedBox(height: 10),
TextField(
controller: tokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Gateway Token', 'Gateway token'),
helperText: tokenMask == null
? null
: '${appText('已保存', 'Stored')}: $tokenMask',
),
),
const SizedBox(height: 10),
TextField(
controller: passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Gateway Password', 'Gateway password'),
helperText: passwordMask == null
? null
: '${appText('已保存', 'Stored')}: $passwordMask',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
'${appText('状态', 'Status')}: $status · $statusEndpoint',
),
),
if (onTlsChanged != null) ...[
Switch(value: tls, onChanged: onTlsChanged),
Text(appText('TLS', 'TLS')),
],
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
onPressed: controller.relayBusy
? null
: () async {
final profile = _gatewayProfileDraft(
executionTarget: executionTarget,
host: hostController.text,
portText: portController.text,
tls: tls,
);
final result = await controller
.testGatewayConnectionDraft(
profile: profile,
executionTarget: executionTarget,
tokenOverride: tokenController.text,
passwordOverride: passwordController.text,
);
if (!mounted) {
return;
}
onMessageChanged(
'${result.state.toUpperCase()} · ${result.message}',
);
},
child: Text(appText('Test', 'Test')),
),
FilledButton(
onPressed: controller.relayBusy
? null
: () async {
await controller.saveRelayConfiguration(
profileIndex: profileIndex,
host: hostController.text,
port: _parsePort(portController.text, fallback: 443),
tls: tls,
token: tokenController.text,
password: passwordController.text,
);
if (!mounted) {
return;
}
onMessageChanged(
appText(
'配置已保存,尚未应用到当前线程连接。',
'Configuration saved but not applied to active thread connections yet.',
),
);
},
child: Text(appText('Save', 'Save')),
),
FilledButton.icon(
onPressed: controller.relayBusy
? null
: () async {
try {
await controller.applyRelayConfiguration(
profileIndex: profileIndex,
host: hostController.text,
port: _parsePort(
portController.text,
fallback: 443,
),
tls: tls,
token: tokenController.text,
password: passwordController.text,
);
if (!mounted) {
return;
}
onMessageChanged(
appText(
'配置已应用;当前线程目标匹配时将使用新连接。',
'Configuration applied. Threads targeting this gateway now use the updated connection.',
),
);
} catch (error) {
if (!mounted) {
return;
}
onMessageChanged('$error');
}
},
icon: controller.relayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_circle_outline_rounded),
label: Text(appText('Apply', 'Apply')),
),
],
),
if (message.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
message,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: context.palette.textSecondary,
),
),
],
],
),
);
}
GatewayConnectionProfile _gatewayProfileDraft({
required AssistantExecutionTarget executionTarget,
required String host,
required String portText,
required bool tls,
}) {
final mode = executionTarget == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final defaults = executionTarget == AssistantExecutionTarget.local
? GatewayConnectionProfile.defaultsLocal()
: GatewayConnectionProfile.defaultsRemote();
return defaults.copyWith(
mode: mode,
host: host.trim(),
port: _parsePort(portText, fallback: defaults.port),
tls: mode == RuntimeConnectionMode.local ? false : tls,
useSetupCode: false,
setupCode: '',
);
}
int _parsePort(String value, {required int fallback}) {
final parsed = int.tryParse(value.trim());
if (parsed == null || parsed <= 0) {
return fallback;
}
return parsed;
}
List<Widget> _buildAppearance(
BuildContext context,
AppController controller,
) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('界面偏好', 'Appearance'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
DropdownButtonFormField<ThemeMode>(
initialValue: controller.themeMode,
items: ThemeMode.values
.map(
(mode) => DropdownMenuItem<ThemeMode>(
value: mode,
child: Text(_themeLabel(mode)),
),
)
.toList(growable: false),
onChanged: (value) {
if (value != null) {
controller.setThemeMode(value);
}
},
decoration: InputDecoration(labelText: appText('主题', 'Theme')),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: controller.toggleAppLanguage,
icon: const Icon(Icons.translate_rounded),
label: Text(
controller.appLanguage == AppLanguage.zh ? '中文' : 'English',
),
),
],
),
),
];
}
List<Widget> _buildAbout(BuildContext context) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'XWorkmate Web',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(kAppVersionLabel),
const SizedBox(height: 8),
Text(
appText(
'Root SPA 目标部署到 https://xworkmate.svc.plus/ 。单机智能体依赖的 LLM API endpoint 需要浏览器可达且支持 CORS否则请使用 Relay 模式。',
'The root SPA targets https://xworkmate.svc.plus/ . Single Agent LLM API endpoints must be browser-reachable and CORS-compatible; otherwise use relay mode.',
),
),
],
),
),
];
}
}
void _setIfDifferent(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,
);
}
String _themeLabel(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => appText('浅色', 'Light'),
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.system => appText('跟随系统', 'System'),
};
}
String _targetLabel(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.singleAgent => appText(
'Single Agent',
'Single Agent',
),
AssistantExecutionTarget.local => appText('Local Gateway', 'Local Gateway'),
AssistantExecutionTarget.remote => appText(
'Remote Gateway',
'Remote Gateway',
),
};
}
enum _StatusChipTone { idle, ready }
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.label, required this.tone});
final String label;
final _StatusChipTone tone;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final background = switch (tone) {
_StatusChipTone.idle => palette.surfaceSecondary,
_StatusChipTone.ready => palette.accent.withValues(alpha: 0.14),
};
final foreground = switch (tone) {
_StatusChipTone.idle => palette.textSecondary,
_StatusChipTone.ready => palette.accent,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w700,
),
),
);
}
}