mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 18:10:29 +00:00
feat: add MCP support (#1471)
This commit is contained in:
83 files changed
+3119
-363
No files matched your search
@@ -16,6 +16,7 @@ import '../../../../app/application/app_platform_controller.dart';
|
||||
import '../../../../app/application/app_runtime_controller.dart';
|
||||
import '../../../../app/application/location_keep_alive.dart';
|
||||
import '../../../../core/common/start_config.dart';
|
||||
import '../../../../core/network/gopeed/gopeed_transport.dart';
|
||||
import '../../../../core/utils/breakpoints.dart';
|
||||
import '../../../../core/window/app_window_chrome.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
@@ -23,6 +24,7 @@ import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||
import '../../../../shared/widgets/app_loading_button.dart';
|
||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../shared/widgets/responsive_menu_layout.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
@@ -34,6 +36,7 @@ import '../../../home/presentation/widgets/primary_rail.dart';
|
||||
import '../../application/settings_controller.dart';
|
||||
import '../widgets/app_update_dialog.dart';
|
||||
import '../widgets/download_categories_setting.dart';
|
||||
import '../widgets/mcp_agent_setup.dart';
|
||||
import '../widgets/settings_language_select.dart';
|
||||
import '../widgets/settings_content_frame.dart';
|
||||
import '../widgets/settings_item.dart';
|
||||
@@ -42,6 +45,11 @@ import '../widgets/theme_settings_control.dart';
|
||||
|
||||
enum _SettingsSection { basic, downloads, advanced }
|
||||
|
||||
String webApiDisplayAddress(Uri pageUri) {
|
||||
final host = pageUri.host.toLowerCase() == 'localhost' ? '127.0.0.1' : pageUri.host;
|
||||
return Uri(host: host, port: pageUri.hasPort ? pageUri.port : null).authority;
|
||||
}
|
||||
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
const SettingsPage({super.key, this.sectionKey});
|
||||
|
||||
@@ -69,7 +77,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
final _proxyUserController = TextEditingController();
|
||||
final _proxyPasswordController = TextEditingController();
|
||||
final _customTrackersController = TextEditingController();
|
||||
final _apiAddressController = TextEditingController();
|
||||
final _apiHostController = TextEditingController();
|
||||
final _apiPortController = TextEditingController();
|
||||
final _apiTokenController = TextEditingController();
|
||||
@@ -82,7 +89,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
bool _syncingControllers = false;
|
||||
bool _syncingStartControllers = false;
|
||||
bool _savingStartConfig = false;
|
||||
String? _apiNetworkDraft;
|
||||
|
||||
List<TextEditingController> get _textControllers => [
|
||||
_downloadDirController,
|
||||
@@ -105,7 +111,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
];
|
||||
|
||||
List<TextEditingController> get _startTextControllers => [
|
||||
_apiAddressController,
|
||||
_apiHostController,
|
||||
_apiPortController,
|
||||
_apiTokenController,
|
||||
@@ -149,7 +154,9 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()),
|
||||
data: (state) {
|
||||
_syncConfig(state.config);
|
||||
if (runtimeState != null) {
|
||||
if (kIsWeb) {
|
||||
_syncWebStartConfig(state.config.api, runtimeState);
|
||||
} else if (runtimeState != null) {
|
||||
_syncStartConfig(runtimeState.startConfig);
|
||||
}
|
||||
return ResponsiveMenuLayout<_SettingsSection>(
|
||||
@@ -279,6 +286,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
) {
|
||||
final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature;
|
||||
final apiServerVisualState = _apiServerVisualState(runtimeState);
|
||||
final palette = AppPalette.of(context);
|
||||
switch (section) {
|
||||
case _SettingsSection.basic:
|
||||
return _SettingsSectionStack(
|
||||
@@ -678,6 +686,90 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
case _SettingsSection.advanced:
|
||||
return _SettingsSectionStack(
|
||||
children: [
|
||||
_SettingsBlock(
|
||||
title: 'API',
|
||||
child: _SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
title: context.l10n.apiServerRuntimeStatus,
|
||||
subtitle: _apiServerRuntimeDetails(runtimeState),
|
||||
child: _ApiServerStatusLight(
|
||||
key: const ValueKey('api-server-runtime-status'),
|
||||
state: apiServerVisualState,
|
||||
label: switch (apiServerVisualState) {
|
||||
_ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
|
||||
_ApiServerVisualState.running => context.l10n.apiServerRunning,
|
||||
_ApiServerVisualState.stopped => context.l10n.apiServerStopped,
|
||||
_ApiServerVisualState.failed => context.l10n.apiServerFailed,
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.listenAddress,
|
||||
child: _HostPortControl(
|
||||
hostController: _apiHostController,
|
||||
portController: _apiPortController,
|
||||
hostKey: const ValueKey('api-host-input'),
|
||||
portKey: const ValueKey('api-port-input'),
|
||||
enabled: !kIsWeb,
|
||||
),
|
||||
),
|
||||
if (!kIsWeb)
|
||||
SettingsItem(
|
||||
title: context.l10n.apiToken,
|
||||
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
|
||||
),
|
||||
McpSettingsItem(
|
||||
enabled: _startConfig?.mcpEnable ?? false,
|
||||
readOnly: kIsWeb,
|
||||
onChanged: (value) => setState(() => _startConfig?.mcpEnable = value),
|
||||
onOpenAgentSetup: () => unawaited(_openMcpAgentSetup(runtimeState)),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
children: [
|
||||
if (kIsWeb)
|
||||
AppTooltip(
|
||||
message: context.l10n.webApiConfigReadOnlyHelp,
|
||||
child: Padding(
|
||||
key: const ValueKey('web-api-config-help'),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(Icons.help_outline, size: 17, color: palette.textMuted),
|
||||
),
|
||||
),
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('toggle-api-server-button'),
|
||||
onPressed: kIsWeb || runtimeState == null ? null : _toggleApiServer,
|
||||
loading: _savingStartConfig,
|
||||
icon: Icon(
|
||||
kIsWeb || (runtimeState?.apiServerState.running ?? false)
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.play_circle_outline,
|
||||
),
|
||||
child: Text(
|
||||
kIsWeb || (runtimeState?.apiServerState.running ?? false)
|
||||
? context.l10n.stopApiServer
|
||||
: context.l10n.startApiServer,
|
||||
),
|
||||
),
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('save-api-config-button'),
|
||||
onPressed: kIsWeb || runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
|
||||
loading: _savingStartConfig,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: Text(context.l10n.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SettingsBlock(
|
||||
title: context.l10n.network,
|
||||
child: _SettingsGroup(
|
||||
@@ -762,94 +854,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!kIsWeb)
|
||||
_SettingsBlock(
|
||||
title: 'API',
|
||||
child: _SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
title: context.l10n.apiServerRuntimeStatus,
|
||||
subtitle: _apiServerRuntimeDetails(runtimeState),
|
||||
child: _ApiServerStatusLight(
|
||||
key: const ValueKey('api-server-runtime-status'),
|
||||
state: apiServerVisualState,
|
||||
label: switch (apiServerVisualState) {
|
||||
_ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
|
||||
_ApiServerVisualState.running => context.l10n.apiServerRunning,
|
||||
_ApiServerVisualState.stopped => context.l10n.apiServerStopped,
|
||||
_ApiServerVisualState.failed => context.l10n.apiServerFailed,
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.protocol,
|
||||
child: AppChoiceSegmentedControl<String>(
|
||||
value: _apiNetworkValue,
|
||||
buttonKeyPrefix: 'settings-choice',
|
||||
options: [
|
||||
const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined),
|
||||
if (Util.supportUnixSocket())
|
||||
const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined),
|
||||
],
|
||||
onChanged: _changeApiNetwork,
|
||||
),
|
||||
),
|
||||
if (_apiNetworkValue == 'unix')
|
||||
SettingsItem(
|
||||
title: context.l10n.socketPath,
|
||||
child: _TextSettingControl(controller: _apiAddressController),
|
||||
)
|
||||
else
|
||||
SettingsItem(
|
||||
title: context.l10n.listenAddress,
|
||||
child: _HostPortControl(
|
||||
hostController: _apiHostController,
|
||||
portController: _apiPortController,
|
||||
hostKey: const ValueKey('api-host-input'),
|
||||
portKey: const ValueKey('api-port-input'),
|
||||
),
|
||||
),
|
||||
if (_apiNetworkValue == 'tcp')
|
||||
SettingsItem(
|
||||
title: context.l10n.apiToken,
|
||||
child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
children: [
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('toggle-api-server-button'),
|
||||
onPressed: runtimeState == null ? null : _toggleApiServer,
|
||||
loading: _savingStartConfig,
|
||||
icon: Icon(
|
||||
runtimeState?.apiServerState.running ?? false
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.play_circle_outline,
|
||||
),
|
||||
child: Text(
|
||||
runtimeState?.apiServerState.running ?? false
|
||||
? context.l10n.stopApiServer
|
||||
: context.l10n.startApiServer,
|
||||
),
|
||||
),
|
||||
AppLoadingButton(
|
||||
key: const ValueKey('save-api-config-button'),
|
||||
onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
|
||||
loading: _savingStartConfig,
|
||||
icon: const Icon(Icons.save_outlined),
|
||||
variant: AppLoadingButtonVariant.primary,
|
||||
child: Text(context.l10n.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SettingsBlock(
|
||||
title: context.l10n.developer,
|
||||
child: _SettingsGroup(
|
||||
@@ -905,8 +909,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
subtitle: _logDirectoryLabel,
|
||||
child: shad.SecondaryButton(
|
||||
onPressed: kIsWeb ? null : _openLogDirectory,
|
||||
leading: const Icon(Icons.folder_open_outlined),
|
||||
child: Text(context.l10n.open),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.folder_open_outlined),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Text(context.l10n.open),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -957,15 +967,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_startConfig!.address == config.address &&
|
||||
_startConfig!.apiToken == config.apiToken) {
|
||||
_startConfig!.apiEnable = config.apiEnable;
|
||||
_startConfig!.mcpEnable = config.mcpEnable;
|
||||
_loadedStartSignature = signature;
|
||||
return;
|
||||
}
|
||||
_loadedStartSignature = signature;
|
||||
_startConfig = _copyStartConfig(config);
|
||||
_apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp';
|
||||
_syncingStartControllers = true;
|
||||
_apiAddressController.text = _startConfig!.address;
|
||||
if (_apiNetworkDraft == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
|
||||
if (config.network == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
|
||||
final apiAddress = _splitHostPort(_startConfig!.address);
|
||||
_apiHostController.text = apiAddress.host;
|
||||
_apiPortController.text = apiAddress.port;
|
||||
@@ -977,6 +986,19 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
_syncingStartControllers = false;
|
||||
}
|
||||
|
||||
void _syncWebStartConfig(ApiServerConfig config, AppRuntimeState? runtimeState) {
|
||||
final webConfig = StartConfig()
|
||||
..network = 'tcp'
|
||||
..address = webApiDisplayAddress(_webApiUri(runtimeState))
|
||||
..apiEnable = true
|
||||
..mcpEnable = config.mcpEnable
|
||||
..storage = ''
|
||||
..storageDir = ''
|
||||
..refreshInterval = 0
|
||||
..apiToken = config.token;
|
||||
_syncStartConfig(webConfig);
|
||||
}
|
||||
|
||||
void _scheduleTextSave() {
|
||||
if (_syncingControllers || _config == null) {
|
||||
return;
|
||||
@@ -1055,10 +1077,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
}
|
||||
|
||||
void _applyStartControllers(StartConfig config) {
|
||||
config.network = _apiNetworkDraft ?? config.network;
|
||||
config.address = config.network == 'unix'
|
||||
? _apiAddressController.text.trim()
|
||||
: _joinHostPort(_apiHostController.text, _apiPortController.text);
|
||||
config.network = 'tcp';
|
||||
config.address = _joinHostPort(_apiHostController.text, _apiPortController.text);
|
||||
config.apiToken = _apiTokenController.text.trim();
|
||||
}
|
||||
|
||||
@@ -1184,10 +1204,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
|
||||
Future<String?> _validateStartConfig(StartConfig candidate) async {
|
||||
final l10n = context.l10n;
|
||||
if (candidate.network == 'unix') {
|
||||
return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null;
|
||||
}
|
||||
|
||||
final address = _splitHostPort(candidate.address);
|
||||
if (address.host.isEmpty) return l10n.serverRequired;
|
||||
final port = int.tryParse(address.port);
|
||||
@@ -1196,29 +1212,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
return null;
|
||||
}
|
||||
|
||||
void _changeApiNetwork(String value) {
|
||||
final config = _startConfig;
|
||||
if (config == null || _apiNetworkDraft == value) return;
|
||||
setState(() {
|
||||
_apiNetworkDraft = value;
|
||||
_syncingStartControllers = true;
|
||||
if (value == 'tcp' && (_apiHostController.text.isEmpty || _apiPortController.text.isEmpty)) {
|
||||
if (_looksLikeTcpAddress(config.address)) {
|
||||
final address = _splitHostPort(config.address);
|
||||
_apiHostController.text = address.host;
|
||||
_apiPortController.text = address.port;
|
||||
} else {
|
||||
_apiHostController.text = '127.0.0.1';
|
||||
_apiPortController.text = '9999';
|
||||
}
|
||||
}
|
||||
if (value == 'unix' && (_apiAddressController.text.isEmpty || _looksLikeTcpAddress(_apiAddressController.text))) {
|
||||
_apiAddressController.text = '${Util.getStorageDir()}/$unixSocketPath';
|
||||
}
|
||||
_syncingStartControllers = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runAction(Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
@@ -1422,15 +1415,12 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
}
|
||||
|
||||
String _startSignature(StartConfig config) {
|
||||
return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}';
|
||||
return '${config.apiEnable}|${config.mcpEnable}|${config.network}|${config.address}|${config.apiToken}';
|
||||
}
|
||||
|
||||
String _startDraftSignature() {
|
||||
final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
|
||||
final address = network == 'unix'
|
||||
? _apiAddressController.text.trim()
|
||||
: _joinHostPort(_apiHostController.text, _apiPortController.text);
|
||||
return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}';
|
||||
final address = _joinHostPort(_apiHostController.text, _apiPortController.text);
|
||||
return '${_startConfig?.apiEnable ?? false}|${_startConfig?.mcpEnable ?? false}|tcp|$address|${_apiTokenController.text.trim()}';
|
||||
}
|
||||
|
||||
StartConfig _copyStartConfig(StartConfig config) {
|
||||
@@ -1438,6 +1428,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
..network = config.network
|
||||
..address = config.address
|
||||
..apiEnable = config.apiEnable
|
||||
..mcpEnable = config.mcpEnable
|
||||
..storage = config.storage
|
||||
..storageDir = config.storageDir
|
||||
..refreshInterval = config.refreshInterval
|
||||
@@ -1445,14 +1436,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
..webViewRpcConfig = config.webViewRpcConfig;
|
||||
}
|
||||
|
||||
String get _apiNetworkValue {
|
||||
final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
|
||||
if (network == 'unix' && Util.supportUnixSocket()) {
|
||||
return 'unix';
|
||||
}
|
||||
return 'tcp';
|
||||
}
|
||||
|
||||
String get _proxyMode {
|
||||
final proxy = _config?.proxy;
|
||||
if (proxy == null || !proxy.enable) return 'none';
|
||||
@@ -1506,6 +1489,40 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openMcpAgentSetup(AppRuntimeState? runtimeState) async {
|
||||
final mcpRunning = kIsWeb
|
||||
? (_startConfig?.mcpEnable ?? false)
|
||||
: runtimeState?.apiServerState.running == true && runtimeState?.apiServerState.mcpEnabled == true;
|
||||
await showMcpAgentSetupDialog(
|
||||
context,
|
||||
endpoint: _mcpEndpoint(runtimeState),
|
||||
apiToken: _apiTokenController.text.trim(),
|
||||
mcpRunning: mcpRunning,
|
||||
allowManualApiTokenTemplate: kIsWeb,
|
||||
);
|
||||
}
|
||||
|
||||
String _mcpEndpoint(AppRuntimeState? runtimeState) {
|
||||
if (kIsWeb) {
|
||||
return webMcpEndpoint(_webApiUri(runtimeState));
|
||||
}
|
||||
|
||||
final apiState = runtimeState?.apiServerState;
|
||||
final address = apiState != null && apiState.running && apiState.network == 'tcp'
|
||||
? apiState.runningAddress()
|
||||
: _joinHostPort(_apiHostController.text, _apiPortController.text);
|
||||
final parts = _splitHostPort(address);
|
||||
var host = parts.host;
|
||||
if (host.startsWith('[') && host.endsWith(']')) {
|
||||
host = host.substring(1, host.length - 1);
|
||||
}
|
||||
if (host == '0.0.0.0' || host == '::') {
|
||||
host = host == '::' ? '::1' : '127.0.0.1';
|
||||
}
|
||||
final port = int.tryParse(parts.port);
|
||||
return Uri(scheme: 'http', host: host, port: port, path: '/mcp').toString();
|
||||
}
|
||||
|
||||
bool _looksLikeTcpAddress(String address) {
|
||||
final separator = address.lastIndexOf(':');
|
||||
if (separator <= 0 || separator == address.length - 1) {
|
||||
@@ -1556,10 +1573,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
|
||||
String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) {
|
||||
if (runtimeState == null) return null;
|
||||
if (kIsWeb) {
|
||||
final apiUri = _webApiUri(runtimeState);
|
||||
return '${apiUri.scheme}://${webApiDisplayAddress(apiUri)}';
|
||||
}
|
||||
final apiState = runtimeState.apiServerState;
|
||||
final details = <String>[];
|
||||
if (apiState.running) {
|
||||
details.add('${apiState.network}://${apiState.runningAddress()}');
|
||||
details.add('http://${apiState.runningAddress()}');
|
||||
}
|
||||
if (apiState.lastError.isNotEmpty) {
|
||||
details.add(apiState.lastError);
|
||||
@@ -1569,8 +1590,14 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
return details.isEmpty ? null : details.join('\n');
|
||||
}
|
||||
|
||||
Uri _webApiUri(AppRuntimeState? runtimeState) {
|
||||
final address = runtimeState?.startConfig.address ?? '';
|
||||
return Uri.parse(normalizeWebApiBaseUrl(address, Uri.base));
|
||||
}
|
||||
|
||||
_ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) {
|
||||
if (runtimeState == null) return _ApiServerVisualState.loading;
|
||||
if (kIsWeb) return _ApiServerVisualState.running;
|
||||
final apiState = runtimeState.apiServerState;
|
||||
if (apiState.running) return _ApiServerVisualState.running;
|
||||
if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed;
|
||||
@@ -1875,12 +1902,14 @@ class _HostPortControl extends StatelessWidget {
|
||||
required this.portController,
|
||||
required this.hostKey,
|
||||
required this.portKey,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController hostController;
|
||||
final TextEditingController portController;
|
||||
final Key hostKey;
|
||||
final Key portKey;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1890,7 +1919,12 @@ class _HostPortControl extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: shad.TextField(key: hostKey, controller: hostController, hintText: context.l10n.server),
|
||||
child: shad.TextField(
|
||||
key: hostKey,
|
||||
controller: hostController,
|
||||
hintText: context.l10n.server,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
SizedBox(
|
||||
@@ -1903,6 +1937,7 @@ class _HostPortControl extends StatelessWidget {
|
||||
step: 1,
|
||||
decimalPlaces: 0,
|
||||
hintText: context.l10n.port,
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1920,6 +1955,7 @@ class _NumberInput extends StatefulWidget {
|
||||
this.fieldKey,
|
||||
this.max,
|
||||
this.hintText,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
@@ -1929,6 +1965,7 @@ class _NumberInput extends StatefulWidget {
|
||||
final int decimalPlaces;
|
||||
final Key? fieldKey;
|
||||
final String? hintText;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<_NumberInput> createState() => _NumberInputState();
|
||||
@@ -1993,6 +2030,7 @@ class _NumberInputState extends State<_NumberInput> {
|
||||
? const TextInputType.numberWithOptions(decimal: true)
|
||||
: TextInputType.number,
|
||||
inputFormatters: formatters,
|
||||
enabled: widget.enabled,
|
||||
features: [
|
||||
shad.InputFeature.spinner(
|
||||
step: widget.step,
|
||||
@@ -2130,8 +2168,14 @@ class _BrowserExtensionLinks extends StatelessWidget {
|
||||
shad.SecondaryButton(
|
||||
key: ValueKey('browser-extension-${link.label.toLowerCase()}'),
|
||||
onPressed: () => onOpen(link.uri),
|
||||
leading: const Icon(Icons.open_in_new, size: 15),
|
||||
child: Text(link.label),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.open_in_new, size: 15),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Text(link.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
+8
-2
@@ -64,8 +64,14 @@ class DownloadCategoriesControl extends StatelessWidget {
|
||||
child: shad.SecondaryButton(
|
||||
key: const ValueKey('add-download-category'),
|
||||
onPressed: onAdd,
|
||||
leading: const Icon(Icons.add, size: 17),
|
||||
child: Text(context.l10n.add),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 17),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Text(context.l10n.add),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart' show Icons, Scrollbar, ScrollbarOrientation, SelectableText;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_copy_icon_button.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import 'settings_item.dart';
|
||||
|
||||
class McpSettingsItem extends StatelessWidget {
|
||||
const McpSettingsItem({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.readOnly,
|
||||
required this.onChanged,
|
||||
required this.onOpenAgentSetup,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final VoidCallback onOpenAgentSetup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return SettingsItem(
|
||||
title: 'MCP',
|
||||
subtitle: context.l10n.mcpDescription,
|
||||
subtitleAction: _McpAgentLink(onPressed: onOpenAgentSetup),
|
||||
titleTrailing: Icon(
|
||||
key: const ValueKey('mcp-ai-icon'),
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 14,
|
||||
color: palette.brandProgress,
|
||||
),
|
||||
child: shad.Switch(
|
||||
key: const ValueKey('mcp-endpoint-switch'),
|
||||
value: enabled,
|
||||
enabled: !readOnly,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpAgentLink extends StatefulWidget {
|
||||
const _McpAgentLink({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_McpAgentLink> createState() => _McpAgentLinkState();
|
||||
}
|
||||
|
||||
class _McpAgentLinkState extends State<_McpAgentLink> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final color = _hovered ? palette.brandProgress : palette.brand;
|
||||
return Semantics(
|
||||
link: true,
|
||||
label: context.l10n.connectAgent,
|
||||
button: true,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('open-mcp-agent-setup'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.connectAgent,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: color,
|
||||
decorationThickness: _hovered ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Icon(Icons.arrow_forward, size: 13, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _McpAgent {
|
||||
codex,
|
||||
claudeCode,
|
||||
cursor,
|
||||
githubCopilot,
|
||||
windsurf,
|
||||
geminiCli,
|
||||
cline,
|
||||
openCode,
|
||||
trae,
|
||||
qoder,
|
||||
workBuddy,
|
||||
zCode,
|
||||
deepSeekHarness,
|
||||
pi,
|
||||
}
|
||||
|
||||
extension on _McpAgent {
|
||||
String get label => switch (this) {
|
||||
_McpAgent.codex => 'Codex',
|
||||
_McpAgent.claudeCode => 'Claude Code',
|
||||
_McpAgent.cursor => 'Cursor',
|
||||
_McpAgent.githubCopilot => 'GitHub Copilot',
|
||||
_McpAgent.windsurf => 'Windsurf',
|
||||
_McpAgent.geminiCli => 'Gemini CLI',
|
||||
_McpAgent.cline => 'Cline',
|
||||
_McpAgent.openCode => 'OpenCode',
|
||||
_McpAgent.trae => 'TRAE',
|
||||
_McpAgent.qoder => 'Qoder',
|
||||
_McpAgent.workBuddy => 'WorkBuddy',
|
||||
_McpAgent.zCode => 'ZCode',
|
||||
_McpAgent.deepSeekHarness => 'DeepSeek Harness',
|
||||
_McpAgent.pi => 'Pi',
|
||||
};
|
||||
|
||||
String get iconAsset => switch (this) {
|
||||
_McpAgent.codex => 'assets/agent_icons/codex.svg',
|
||||
_McpAgent.claudeCode => 'assets/agent_icons/claude_code.svg',
|
||||
_McpAgent.cursor => 'assets/agent_icons/cursor.svg',
|
||||
_McpAgent.githubCopilot => 'assets/agent_icons/github_copilot.svg',
|
||||
_McpAgent.windsurf => 'assets/agent_icons/windsurf.svg',
|
||||
_McpAgent.geminiCli => 'assets/agent_icons/gemini_cli.svg',
|
||||
_McpAgent.cline => 'assets/agent_icons/cline.svg',
|
||||
_McpAgent.openCode => 'assets/agent_icons/opencode.svg',
|
||||
_McpAgent.trae => 'assets/agent_icons/trae.svg',
|
||||
_McpAgent.qoder => 'assets/agent_icons/qoder.svg',
|
||||
_McpAgent.workBuddy => 'assets/agent_icons/codebuddy.svg',
|
||||
_McpAgent.zCode => 'assets/agent_icons/zcode.svg',
|
||||
_McpAgent.deepSeekHarness => 'assets/agent_icons/deepseek.svg',
|
||||
_McpAgent.pi => 'assets/agent_icons/pi.svg',
|
||||
};
|
||||
|
||||
bool get usesThemeTint => switch (this) {
|
||||
_McpAgent.cursor ||
|
||||
_McpAgent.githubCopilot ||
|
||||
_McpAgent.windsurf ||
|
||||
_McpAgent.cline ||
|
||||
_McpAgent.openCode ||
|
||||
_McpAgent.zCode ||
|
||||
_McpAgent.pi => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
String webMcpEndpoint(Uri pageUri) {
|
||||
return Uri(
|
||||
scheme: pageUri.scheme,
|
||||
host: pageUri.host,
|
||||
port: pageUri.hasPort ? pageUri.port : null,
|
||||
path: '/mcp',
|
||||
).toString();
|
||||
}
|
||||
|
||||
Future<void> showMcpAgentSetupDialog(
|
||||
BuildContext context, {
|
||||
required String endpoint,
|
||||
required String apiToken,
|
||||
required bool mcpRunning,
|
||||
bool allowManualApiTokenTemplate = false,
|
||||
}) async {
|
||||
var selectedAgent = _McpAgent.codex;
|
||||
var useManualApiTokenTemplate = false;
|
||||
final overlay = const shad.DialogOverlayHandler().show<void>(
|
||||
context: context,
|
||||
alignment: Alignment.center,
|
||||
barrierDismissable: true,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
final palette = AppPalette.of(dialogContext);
|
||||
final screenSize = MediaQuery.sizeOf(dialogContext);
|
||||
final dialogWidth = (screenSize.width - 80).clamp(220.0, 620.0);
|
||||
final contentMaxHeight = (screenSize.height - 140).clamp(120.0, 460.0);
|
||||
final templateToken = allowManualApiTokenTemplate && useManualApiTokenTemplate ? '<API_TOKEN>' : '';
|
||||
final displayToken = allowManualApiTokenTemplate ? templateToken : _maskedToken(apiToken);
|
||||
final clipboardToken = allowManualApiTokenTemplate ? templateToken : apiToken;
|
||||
final displaySnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: displayToken);
|
||||
final clipboardSnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: clipboardToken);
|
||||
|
||||
return shad.AlertDialog(
|
||||
key: const ValueKey('mcp-agent-setup-dialog'),
|
||||
padding: const EdgeInsets.all(AppDesignTokens.space24),
|
||||
title: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.brandSoft,
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.auto_awesome_outlined, size: 19, color: palette.brandProgress),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(dialogContext.l10n.connectMcpAgent),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
dialogContext.l10n.mcpAgentDialogDescription,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AppTooltip(
|
||||
message: dialogContext.l10n.close,
|
||||
child: shad.IconButton.ghost(
|
||||
key: const ValueKey('close-mcp-agent-setup'),
|
||||
size: shad.ButtonSize.xSmall,
|
||||
onPressed: () => shad.closeOverlay(dialogContext),
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: contentMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!mcpRunning) ...[
|
||||
Container(
|
||||
key: const ValueKey('mcp-not-running-hint'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDesignTokens.space12,
|
||||
vertical: AppDesignTokens.space8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.brandSoft,
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
border: Border.all(color: palette.brandTrack),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: palette.brandProgress),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
dialogContext.l10n.mcpNotRunningHint,
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDesignTokens.space16),
|
||||
],
|
||||
_McpAgentGrid(
|
||||
selected: selectedAgent,
|
||||
onSelected: (value) => setDialogState(() => selectedAgent = value),
|
||||
),
|
||||
const SizedBox(height: AppDesignTokens.space16),
|
||||
Row(
|
||||
children: [
|
||||
_McpAgentLogo(agent: selectedAgent, size: 20),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${selectedAgent.label} · MCP',
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (allowManualApiTokenTemplate) ...[
|
||||
const SizedBox(width: 2),
|
||||
_McpApiTokenTemplateHint(
|
||||
key: ValueKey('mcp-api-token-template-help-$useManualApiTokenTemplate'),
|
||||
onUseTemplate: () => setDialogState(() => useManualApiTokenTemplate = true),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
AppCopyIconButton(key: const ValueKey('copy-mcp-agent-snippet'), text: clipboardSnippet),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDesignTokens.space8),
|
||||
_McpSnippetBox(snippet: displaySnippet),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await overlay.future;
|
||||
}
|
||||
|
||||
class _McpApiTokenTemplateHint extends StatelessWidget {
|
||||
const _McpApiTokenTemplateHint({super.key, required this.onUseTemplate});
|
||||
|
||||
final VoidCallback onUseTemplate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return shad.HoverCard(
|
||||
wait: const Duration(milliseconds: 180),
|
||||
debounce: const Duration(milliseconds: 350),
|
||||
popoverAlignment: Alignment.topLeft,
|
||||
anchorAlignment: Alignment.bottomLeft,
|
||||
popoverOffset: const Offset(0, 6),
|
||||
hoverBuilder: (hoverContext) {
|
||||
final hoverPalette = AppPalette.of(hoverContext);
|
||||
return Container(
|
||||
key: const ValueKey('mcp-api-token-template-hint'),
|
||||
width: 288,
|
||||
padding: const EdgeInsets.all(AppDesignTokens.space12),
|
||||
decoration: BoxDecoration(
|
||||
color: hoverPalette.cardBg,
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
border: Border.all(color: hoverPalette.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withValues(alpha: 0.16),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
hoverContext.l10n.webMcpApiTokenHint,
|
||||
style: TextStyle(color: hoverPalette.textSecondary, fontSize: 12, height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Semantics(
|
||||
link: true,
|
||||
button: true,
|
||||
label: hoverContext.l10n.useMcpApiTokenTemplate,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('use-mcp-api-token-template'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onUseTemplate,
|
||||
child: Text(
|
||||
hoverContext.l10n.useMcpApiTokenTemplate,
|
||||
style: TextStyle(
|
||||
color: hoverPalette.brand,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: hoverPalette.brand,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Semantics(
|
||||
label: context.l10n.webMcpApiTokenHint,
|
||||
child: SizedBox(
|
||||
key: const ValueKey('mcp-api-token-template-help'),
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Icon(Icons.help_outline, size: 16, color: palette.textMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpAgentGrid extends StatelessWidget {
|
||||
const _McpAgentGrid({required this.selected, required this.onSelected});
|
||||
|
||||
final _McpAgent selected;
|
||||
final ValueChanged<_McpAgent> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columns = switch (constraints.maxWidth) {
|
||||
>= 520 => 8,
|
||||
>= 380 => 6,
|
||||
>= 260 => 5,
|
||||
_ => 4,
|
||||
};
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: AppDesignTokens.space8,
|
||||
mainAxisSpacing: AppDesignTokens.space8,
|
||||
),
|
||||
itemCount: _McpAgent.values.length,
|
||||
itemBuilder: (context, index) {
|
||||
final agent = _McpAgent.values[index];
|
||||
return _McpAgentButton(
|
||||
key: ValueKey('mcp-agent-${_agentKey(agent)}'),
|
||||
agent: agent,
|
||||
selected: agent == selected,
|
||||
onPressed: () => onSelected(agent),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpAgentButton extends StatefulWidget {
|
||||
const _McpAgentButton({super.key, required this.agent, required this.selected, required this.onPressed});
|
||||
|
||||
final _McpAgent agent;
|
||||
final bool selected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_McpAgentButton> createState() => _McpAgentButtonState();
|
||||
}
|
||||
|
||||
class _McpAgentButtonState extends State<_McpAgentButton> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final background = widget.selected
|
||||
? palette.brandSoft
|
||||
: _hovered
|
||||
? palette.cardHoverBg
|
||||
: palette.cardBg;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: widget.selected,
|
||||
label: widget.agent.label,
|
||||
child: AppTooltip(
|
||||
message: widget.agent.label,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 2),
|
||||
border: Border.all(
|
||||
color: widget.selected ? palette.brandProgress : palette.border,
|
||||
width: widget.selected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _McpAgentLogo(agent: widget.agent, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpAgentLogo extends StatelessWidget {
|
||||
const _McpAgentLogo({required this.agent, required this.size});
|
||||
|
||||
final _McpAgent agent;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return SvgPicture.asset(
|
||||
agent.iconAsset,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
excludeFromSemantics: true,
|
||||
colorFilter: agent.usesThemeTint ? ColorFilter.mode(palette.textPrimary, BlendMode.srcIn) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpSnippetBox extends StatefulWidget {
|
||||
const _McpSnippetBox({required this.snippet});
|
||||
|
||||
final String snippet;
|
||||
|
||||
@override
|
||||
State<_McpSnippetBox> createState() => _McpSnippetBoxState();
|
||||
}
|
||||
|
||||
class _McpSnippetBoxState extends State<_McpSnippetBox> {
|
||||
final _horizontalController = ScrollController();
|
||||
final _verticalController = ScrollController();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _McpSnippetBox oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.snippet == oldWidget.snippet) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_horizontalController.hasClients) _horizontalController.jumpTo(0);
|
||||
if (_verticalController.hasClients) _verticalController.jumpTo(0);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_horizontalController.dispose();
|
||||
_verticalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
return Container(
|
||||
key: const ValueKey('mcp-agent-snippet'),
|
||||
height: 176,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSoft,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: palette.border),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp(0.0, double.infinity);
|
||||
return Scrollbar(
|
||||
key: const ValueKey('mcp-snippet-horizontal-scrollbar'),
|
||||
controller: _horizontalController,
|
||||
thumbVisibility: true,
|
||||
scrollbarOrientation: ScrollbarOrientation.bottom,
|
||||
notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppDesignTokens.space12),
|
||||
child: SingleChildScrollView(
|
||||
controller: _horizontalController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SizedBox(
|
||||
height: contentHeight,
|
||||
child: Scrollbar(
|
||||
key: const ValueKey('mcp-snippet-vertical-scrollbar'),
|
||||
controller: _verticalController,
|
||||
thumbVisibility: true,
|
||||
scrollbarOrientation: ScrollbarOrientation.right,
|
||||
notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
|
||||
child: SingleChildScrollView(
|
||||
controller: _verticalController,
|
||||
child: IntrinsicWidth(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: SelectableText(
|
||||
widget.snippet,
|
||||
style: TextStyle(
|
||||
color: palette.textPrimary,
|
||||
fontSize: 12,
|
||||
height: 1.55,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _agentKey(_McpAgent agent) => switch (agent) {
|
||||
_McpAgent.cursor => 'cursor',
|
||||
_McpAgent.claudeCode => 'claude-code',
|
||||
_McpAgent.codex => 'codex',
|
||||
_McpAgent.githubCopilot => 'github-copilot',
|
||||
_McpAgent.windsurf => 'windsurf',
|
||||
_McpAgent.geminiCli => 'gemini-cli',
|
||||
_McpAgent.cline => 'cline',
|
||||
_McpAgent.openCode => 'opencode',
|
||||
_McpAgent.trae => 'trae',
|
||||
_McpAgent.qoder => 'qoder',
|
||||
_McpAgent.workBuddy => 'workbuddy',
|
||||
_McpAgent.zCode => 'zcode',
|
||||
_McpAgent.deepSeekHarness => 'deepseek-harness',
|
||||
_McpAgent.pi => 'pi',
|
||||
};
|
||||
|
||||
String _agentSnippet(_McpAgent agent, {required String endpoint, required String apiToken}) {
|
||||
return switch (agent) {
|
||||
_McpAgent.cursor => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.codex => _codexSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.claudeCode =>
|
||||
apiToken.isNotEmpty
|
||||
? 'claude mcp add --transport http gopeed $endpoint \\\n'
|
||||
" --header ${_shellSingleQuote('Authorization: Bearer $apiToken')}"
|
||||
: 'claude mcp add --transport http gopeed $endpoint',
|
||||
_McpAgent.githubCopilot => _mcpServersSnippet(
|
||||
rootKey: 'servers',
|
||||
type: 'http',
|
||||
endpoint: endpoint,
|
||||
apiToken: apiToken,
|
||||
),
|
||||
_McpAgent.windsurf => _mcpServersSnippet(urlKey: 'serverUrl', endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.geminiCli => _mcpServersSnippet(urlKey: 'httpUrl', endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.cline => _mcpServersSnippet(
|
||||
type: 'streamableHttp',
|
||||
endpoint: endpoint,
|
||||
apiToken: apiToken,
|
||||
extra: const <String, Object>{'disabled': false, 'autoApprove': <Object>[]},
|
||||
),
|
||||
_McpAgent.openCode => _openCodeSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.trae => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.qoder => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.workBuddy => _mcpServersSnippet(
|
||||
type: 'streamableHttp',
|
||||
endpoint: endpoint,
|
||||
apiToken: apiToken,
|
||||
extra: const <String, Object>{'timeout': 30000},
|
||||
),
|
||||
_McpAgent.zCode => _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.deepSeekHarness => _deepSeekHarnessSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
_McpAgent.pi => _piSnippet(endpoint: endpoint, apiToken: apiToken),
|
||||
};
|
||||
}
|
||||
|
||||
String _codexSnippet({required String endpoint, required String apiToken}) {
|
||||
if (apiToken.isEmpty) {
|
||||
return 'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)}';
|
||||
}
|
||||
return 'export GOPEED_API_TOKEN=${_shellSingleQuote(apiToken)}\n\n'
|
||||
'codex mcp add gopeed --url ${_shellSingleQuote(endpoint)} \\\n'
|
||||
' --bearer-token-env-var GOPEED_API_TOKEN';
|
||||
}
|
||||
|
||||
String _mcpServersSnippet({
|
||||
String rootKey = 'mcpServers',
|
||||
String urlKey = 'url',
|
||||
String? type,
|
||||
required String endpoint,
|
||||
required String apiToken,
|
||||
Map<String, Object> extra = const <String, Object>{},
|
||||
}) {
|
||||
final server = <String, Object>{};
|
||||
if (type != null) {
|
||||
server['type'] = type;
|
||||
}
|
||||
server[urlKey] = endpoint;
|
||||
if (apiToken.isNotEmpty) {
|
||||
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
|
||||
}
|
||||
server.addAll(extra);
|
||||
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
|
||||
rootKey: <String, Object>{'gopeed': server},
|
||||
});
|
||||
}
|
||||
|
||||
String _openCodeSnippet({required String endpoint, required String apiToken}) {
|
||||
final server = <String, Object>{'type': 'remote', 'url': endpoint, 'oauth': false};
|
||||
if (apiToken.isNotEmpty) {
|
||||
server['headers'] = <String, String>{'Authorization': 'Bearer $apiToken'};
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(<String, Object>{
|
||||
r'$schema': 'https://opencode.ai/config.json',
|
||||
'mcp': <String, Object>{
|
||||
'servers': <String, Object>{'gopeed': server},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
String _deepSeekHarnessSnippet({required String endpoint, required String apiToken}) {
|
||||
final headers = apiToken.isNotEmpty
|
||||
? '\n headers:\n Authorization: ${jsonEncode('Bearer $apiToken')}'
|
||||
: '\n headers: {}';
|
||||
return '- id: mcp-gopeed\n'
|
||||
" name: '@deepseek-ai/dsh-mcp-client'\n"
|
||||
' config:\n'
|
||||
' serverName: gopeed\n'
|
||||
' transport: streamable-http\n'
|
||||
' url: $endpoint$headers';
|
||||
}
|
||||
|
||||
String _piSnippet({required String endpoint, required String apiToken}) {
|
||||
final config = _mcpServersSnippet(type: 'http', endpoint: endpoint, apiToken: apiToken);
|
||||
return 'pi install npm:pi-codemcp\n\n# ~/.pi/agent/mcp.json\n$config';
|
||||
}
|
||||
|
||||
String _maskedToken(String token) {
|
||||
if (token.isEmpty) return '';
|
||||
if (token.length <= 8) return '••••••••';
|
||||
return '${token.substring(0, 4)}••••••••${token.substring(token.length - 4)}';
|
||||
}
|
||||
|
||||
String _shellSingleQuote(String value) => "'${value.replaceAll("'", "'\"'\"'")}'";
|
||||
@@ -5,10 +5,19 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
|
||||
class SettingsItem extends StatelessWidget {
|
||||
const SettingsItem({super.key, required this.title, required this.child, this.subtitle});
|
||||
const SettingsItem({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.subtitle,
|
||||
this.subtitleAction,
|
||||
this.titleTrailing,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? subtitleAction;
|
||||
final Widget? titleTrailing;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -29,7 +38,12 @@ class SettingsItem extends StatelessWidget {
|
||||
children: [
|
||||
const SizedBox(width: leadingSpace),
|
||||
Expanded(
|
||||
child: _SettingsItemLabel(title: title, subtitle: subtitle),
|
||||
child: _SettingsItemLabel(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
subtitleAction: subtitleAction,
|
||||
titleTrailing: titleTrailing,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: columnGap),
|
||||
ConstrainedBox(
|
||||
@@ -45,7 +59,12 @@ class SettingsItem extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingsItemLabel(title: title, subtitle: subtitle),
|
||||
_SettingsItemLabel(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
subtitleAction: subtitleAction,
|
||||
titleTrailing: titleTrailing,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Align(alignment: Alignment.centerLeft, child: child),
|
||||
],
|
||||
@@ -54,10 +73,12 @@ class SettingsItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _SettingsItemLabel extends StatelessWidget {
|
||||
const _SettingsItemLabel({required this.title, this.subtitle});
|
||||
const _SettingsItemLabel({required this.title, this.subtitle, this.subtitleAction, this.titleTrailing});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? subtitleAction;
|
||||
final Widget? titleTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -66,13 +87,30 @@ class _SettingsItemLabel extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (titleTrailing != null) ...[const SizedBox(width: 5), titleTrailing!],
|
||||
],
|
||||
),
|
||||
if (subtitleText != null && subtitleText.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
runSpacing: 2,
|
||||
children: [
|
||||
Text(subtitleText, style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.3)),
|
||||
?subtitleAction,
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -99,8 +99,14 @@ class SettingsListEditor extends StatelessWidget {
|
||||
child: shad.SecondaryButton(
|
||||
key: addButtonKey,
|
||||
onPressed: onAdd,
|
||||
leading: const Icon(Icons.add, size: 17),
|
||||
child: Text(addLabel ?? context.l10n.add),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 17),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
Text(addLabel ?? context.l10n.add),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -280,8 +286,16 @@ Future<GithubMirrorDraft?> showGithubMirrorDialog(BuildContext context, {GithubM
|
||||
for (final type in GithubMirrorType.values)
|
||||
shad.SecondaryButton(
|
||||
onPressed: () => setDialogState(() => selectedType = type),
|
||||
leading: selectedType == type ? const Icon(Icons.check, size: 15) : null,
|
||||
child: Text(type.name),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (selectedType == type) ...[
|
||||
const Icon(Icons.check, size: 15),
|
||||
const SizedBox(width: AppDesignTokens.space8),
|
||||
],
|
||||
Text(type.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user