mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 17:08:29 +08:00
feat: native 端支持外接 mcp 操作
This commit is contained in:
@@ -237,6 +237,20 @@ class ApiClient {
|
||||
return _request(() => _dio.post(path, data: data));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postJsonWithReceiveTimeout(
|
||||
String path,
|
||||
Map<String, dynamic> data,
|
||||
Duration receiveTimeout,
|
||||
) async {
|
||||
return _request(
|
||||
() => _dio.post(
|
||||
path,
|
||||
data: data,
|
||||
options: Options(receiveTimeout: receiveTimeout),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> putJson(
|
||||
String path,
|
||||
Map<String, dynamic> data,
|
||||
@@ -244,6 +258,20 @@ class ApiClient {
|
||||
return _request(() => _dio.put(path, data: data));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> putJsonWithReceiveTimeout(
|
||||
String path,
|
||||
Map<String, dynamic> data,
|
||||
Duration receiveTimeout,
|
||||
) async {
|
||||
return _request(
|
||||
() => _dio.put(
|
||||
path,
|
||||
data: data,
|
||||
options: Options(receiveTimeout: receiveTimeout),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> patchJson(
|
||||
String path,
|
||||
Map<String, dynamic> data,
|
||||
|
||||
@@ -72,10 +72,17 @@ class _AgentApprovalCardState extends ConsumerState<AgentApprovalCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l.trf('agent.approval.description', [
|
||||
item.hostName ?? '-',
|
||||
_toolLabel(l, item.tool),
|
||||
]),
|
||||
item.isMcp
|
||||
? l.trf('agent.approval.providerDescription', [
|
||||
item.providerName ??
|
||||
item.toolInfo?['providerName']?.toString() ??
|
||||
'MCP',
|
||||
item.displayName,
|
||||
])
|
||||
: l.trf('agent.approval.description', [
|
||||
item.hostName ?? '-',
|
||||
_toolLabel(l, item.tool),
|
||||
]),
|
||||
),
|
||||
if (item.effect != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
@@ -142,7 +149,11 @@ class _AgentApprovalCardState extends ConsumerState<AgentApprovalCard> {
|
||||
onPressed: () => ref
|
||||
.read(agentControllerProvider.notifier)
|
||||
.approve(item.requestId, true, scope: 'session'),
|
||||
child: Text(l.tr('agent.approval.allowSession')),
|
||||
child: Text(
|
||||
item.isMcp
|
||||
? l.tr('agent.approval.allowMcpSession')
|
||||
: l.tr('agent.approval.allowSession'),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ref
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'agent_models.dart';
|
||||
|
||||
class AgentMcpTool {
|
||||
AgentMcpTool({
|
||||
required this.remoteName,
|
||||
required this.exposedName,
|
||||
required this.displayName,
|
||||
required this.description,
|
||||
required this.inputSchema,
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
final String remoteName;
|
||||
final String exposedName;
|
||||
final String displayName;
|
||||
final String description;
|
||||
final Map<String, dynamic> inputSchema;
|
||||
final bool enabled;
|
||||
|
||||
factory AgentMcpTool.fromJson(Map<String, dynamic> json) => AgentMcpTool(
|
||||
remoteName: json['remoteName']?.toString() ?? '',
|
||||
exposedName: json['exposedName']?.toString() ?? '',
|
||||
displayName: json['displayName']?.toString() ?? '',
|
||||
description: json['description']?.toString() ?? '',
|
||||
inputSchema: UnmodifiableMapView(stringMap(json['inputSchema'])),
|
||||
enabled: json['enabled'] != false,
|
||||
);
|
||||
|
||||
AgentMcpTool copyWith({bool? enabled}) => AgentMcpTool(
|
||||
remoteName: remoteName,
|
||||
exposedName: exposedName,
|
||||
displayName: displayName,
|
||||
description: description,
|
||||
inputSchema: inputSchema,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toUpdateJson() => {
|
||||
'remoteName': remoteName,
|
||||
'enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
class AgentMcpServer {
|
||||
AgentMcpServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.key,
|
||||
required this.url,
|
||||
required this.enabled,
|
||||
required this.connectTimeoutSeconds,
|
||||
required this.callTimeoutSeconds,
|
||||
required this.headers,
|
||||
required this.tools,
|
||||
required this.lastError,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String key;
|
||||
final String url;
|
||||
final bool enabled;
|
||||
final int connectTimeoutSeconds;
|
||||
final int callTimeoutSeconds;
|
||||
final Map<String, String> headers;
|
||||
final List<AgentMcpTool> tools;
|
||||
final String lastError;
|
||||
|
||||
factory AgentMcpServer.fromJson(Map<String, dynamic> json) {
|
||||
final rawHeaders = stringMap(json['headers']);
|
||||
return AgentMcpServer(
|
||||
id: (json['_id'] ?? json['id'] ?? '').toString(),
|
||||
name: json['name']?.toString() ?? '',
|
||||
key: json['key']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
enabled: json['enabled'] != false,
|
||||
connectTimeoutSeconds: intValue(
|
||||
json['connectTimeoutSeconds'],
|
||||
10,
|
||||
).clamp(1, 60),
|
||||
callTimeoutSeconds: intValue(json['callTimeoutSeconds'], 60).clamp(1, 60),
|
||||
headers: UnmodifiableMapView(
|
||||
rawHeaders.map((key, value) => MapEntry(key, value.toString())),
|
||||
),
|
||||
tools: mapList(json['tools'])
|
||||
.map(AgentMcpTool.fromJson)
|
||||
.where((tool) => tool.remoteName.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
lastError: json['lastError']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
int get enabledToolCount => tools.where((tool) => tool.enabled).length;
|
||||
bool get hasAuthorization =>
|
||||
headers.keys.any((name) => name.toLowerCase() == 'authorization');
|
||||
|
||||
AgentMcpServer copyWith({bool? enabled, List<AgentMcpTool>? tools}) =>
|
||||
AgentMcpServer(
|
||||
id: id,
|
||||
name: name,
|
||||
key: key,
|
||||
url: url,
|
||||
enabled: enabled ?? this.enabled,
|
||||
connectTimeoutSeconds: connectTimeoutSeconds,
|
||||
callTimeoutSeconds: callTimeoutSeconds,
|
||||
headers: headers,
|
||||
tools: tools ?? this.tools,
|
||||
lastError: lastError,
|
||||
);
|
||||
}
|
||||
@@ -268,6 +268,7 @@ class AgentToolPart extends AgentMessagePart {
|
||||
this.durationMs,
|
||||
this.risk,
|
||||
this.approval,
|
||||
this.toolInfo,
|
||||
});
|
||||
|
||||
final String toolCallId;
|
||||
@@ -279,6 +280,14 @@ class AgentToolPart extends AgentMessagePart {
|
||||
final int? durationMs;
|
||||
final Map<String, dynamic>? risk;
|
||||
final Map<String, dynamic>? approval;
|
||||
final Map<String, dynamic>? toolInfo;
|
||||
|
||||
bool get isMcp => toolInfo?['source'] == 'mcp';
|
||||
String get displayName =>
|
||||
toolInfo?['displayName']?.toString().trim().isNotEmpty == true
|
||||
? toolInfo!['displayName'].toString()
|
||||
: tool;
|
||||
String get providerName => toolInfo?['providerName']?.toString() ?? '';
|
||||
|
||||
AgentToolPart copyWith({
|
||||
AgentToolStatus? status,
|
||||
@@ -289,6 +298,7 @@ class AgentToolPart extends AgentMessagePart {
|
||||
int? durationMs,
|
||||
Map<String, dynamic>? risk,
|
||||
Map<String, dynamic>? approval,
|
||||
Map<String, dynamic>? toolInfo,
|
||||
}) => AgentToolPart(
|
||||
toolCallId: toolCallId,
|
||||
tool: tool,
|
||||
@@ -299,6 +309,7 @@ class AgentToolPart extends AgentMessagePart {
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
risk: risk ?? this.risk,
|
||||
approval: approval ?? this.approval,
|
||||
toolInfo: toolInfo ?? this.toolInfo,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,6 +362,8 @@ class AgentApproval {
|
||||
this.risk,
|
||||
this.grantLabel,
|
||||
this.grantable = true,
|
||||
this.providerName,
|
||||
this.toolInfo,
|
||||
});
|
||||
|
||||
final String requestId;
|
||||
@@ -366,6 +379,14 @@ class AgentApproval {
|
||||
final Map<String, dynamic>? risk;
|
||||
final String? grantLabel;
|
||||
final bool grantable;
|
||||
final String? providerName;
|
||||
final Map<String, dynamic>? toolInfo;
|
||||
|
||||
bool get isMcp => toolInfo?['source'] == 'mcp';
|
||||
String get displayName =>
|
||||
toolInfo?['displayName']?.toString().trim().isNotEmpty == true
|
||||
? toolInfo!['displayName'].toString()
|
||||
: tool;
|
||||
|
||||
factory AgentApproval.fromEvent(Map<String, dynamic> event) => AgentApproval(
|
||||
requestId: event['requestId']?.toString() ?? '',
|
||||
@@ -384,6 +405,8 @@ class AgentApproval {
|
||||
risk: event['risk'] is Map ? stringMap(event['risk']) : null,
|
||||
grantLabel: event['grantLabel']?.toString(),
|
||||
grantable: event['grantable'] != false,
|
||||
providerName: event['providerName']?.toString(),
|
||||
toolInfo: event['toolInfo'] is Map ? stringMap(event['toolInfo']) : null,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,12 @@ AgentConversationState applyAgentEvent(
|
||||
return _updateTool(
|
||||
state,
|
||||
event['toolCallId']?.toString() ?? '',
|
||||
(part) => part.copyWith(durationMs: intValue(event['durationMs'])),
|
||||
(part) => part.copyWith(
|
||||
durationMs: intValue(event['durationMs']),
|
||||
toolInfo: event['toolInfo'] is Map
|
||||
? stringMap(event['toolInfo'])
|
||||
: null,
|
||||
),
|
||||
);
|
||||
case 'tool_result':
|
||||
return _updateTool(
|
||||
@@ -171,11 +176,17 @@ AgentConversationState applyAgentEvent(
|
||||
? AgentToolStatus.denied
|
||||
: AgentToolStatus.error,
|
||||
error: event['error'].toString(),
|
||||
toolInfo: event['toolInfo'] is Map
|
||||
? stringMap(event['toolInfo'])
|
||||
: null,
|
||||
)
|
||||
: part.copyWith(
|
||||
status: AgentToolStatus.done,
|
||||
output: normalizeAgentToolOutput(event['output']),
|
||||
clearError: true,
|
||||
toolInfo: event['toolInfo'] is Map
|
||||
? stringMap(event['toolInfo'])
|
||||
: null,
|
||||
),
|
||||
);
|
||||
case 'awaiting_model':
|
||||
@@ -206,6 +217,7 @@ AgentConversationState applyAgentEvent(
|
||||
(part) => part.copyWith(
|
||||
status: AgentToolStatus.awaitingApproval,
|
||||
risk: approval.risk,
|
||||
toolInfo: approval.toolInfo,
|
||||
),
|
||||
);
|
||||
return updated.copyWith(
|
||||
@@ -326,6 +338,9 @@ AgentConversationState _appendTool(
|
||||
? AgentToolStatus.running
|
||||
: AgentToolStatus.awaitingApproval,
|
||||
risk: pending?.risk,
|
||||
toolInfo: event['toolInfo'] is Map
|
||||
? stringMap(event['toolInfo'])
|
||||
: pending?.toolInfo,
|
||||
),
|
||||
);
|
||||
messages[messages.length - 1] = message.copyWith(parts: parts);
|
||||
@@ -499,6 +514,9 @@ List<AgentMessage> messagesFromAgentSession(AgentSession session) {
|
||||
'scope': meta['approvalScope'],
|
||||
'cached': meta['approvalCached'],
|
||||
},
|
||||
toolInfo: meta['toolInfo'] is Map
|
||||
? stringMap(meta['toolInfo'])
|
||||
: null,
|
||||
);
|
||||
parts.add(tool);
|
||||
toolParts[id] = tool;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import 'agent_mcp_models.dart';
|
||||
import 'agent_models.dart';
|
||||
|
||||
const _mcpDiscoveryTimeout = Duration(seconds: 130);
|
||||
|
||||
class AgentRepository {
|
||||
const AgentRepository(this._api);
|
||||
|
||||
@@ -47,6 +50,56 @@ class AgentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<AgentMcpServer>> getMcpServers() async {
|
||||
final response = await _api.getJson('/agent/mcp-servers');
|
||||
return mapList(response['data'])
|
||||
.map(AgentMcpServer.fromJson)
|
||||
.where((server) => server.id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<AgentMcpServer> createMcpServer(Map<String, dynamic> payload) async {
|
||||
final response = await _api.postJsonWithReceiveTimeout(
|
||||
'/agent/mcp-servers',
|
||||
payload,
|
||||
_mcpDiscoveryTimeout,
|
||||
);
|
||||
return AgentMcpServer.fromJson(stringMap(response['data']));
|
||||
}
|
||||
|
||||
Future<AgentMcpServer> updateMcpServer(
|
||||
String id,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final response = await _api.putJsonWithReceiveTimeout(
|
||||
'/agent/mcp-servers/$id',
|
||||
payload,
|
||||
_mcpDiscoveryTimeout,
|
||||
);
|
||||
return AgentMcpServer.fromJson(stringMap(response['data']));
|
||||
}
|
||||
|
||||
Future<int> testMcpConnection(Map<String, dynamic> payload) async {
|
||||
final response = await _api.postJsonWithReceiveTimeout(
|
||||
'/agent/mcp-servers/test-connection',
|
||||
payload,
|
||||
_mcpDiscoveryTimeout,
|
||||
);
|
||||
return intValue(stringMap(response['data'])['toolCount']);
|
||||
}
|
||||
|
||||
Future<AgentMcpServer> discoverMcpServer(String id) async {
|
||||
final response = await _api.postJsonWithReceiveTimeout(
|
||||
'/agent/mcp-servers/$id/discover',
|
||||
const {},
|
||||
_mcpDiscoveryTimeout,
|
||||
);
|
||||
return AgentMcpServer.fromJson(stringMap(response['data']));
|
||||
}
|
||||
|
||||
Future<void> deleteMcpServer(String id) =>
|
||||
_api.deleteJson('/agent/mcp-servers/$id');
|
||||
|
||||
Future<List<AgentSessionSummary>> getSessions() async {
|
||||
final response = await _api.getJson(
|
||||
'/agent-sessions',
|
||||
|
||||
@@ -101,15 +101,51 @@ class _AgentToolCardState extends State<AgentToolCard> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_toolLabel(l, part.tool),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: context.colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
part.isMcp
|
||||
? part.displayName
|
||||
: _toolLabel(l, part.tool),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: context.colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (part.isMcp) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 132,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.accentSoft,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
part.providerName.isEmpty
|
||||
? 'MCP'
|
||||
: 'MCP · ${part.providerName}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: context.colors.primary,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (summary.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
@@ -341,12 +377,19 @@ String _toolLabel(AppLocalizations l, String tool) {
|
||||
|
||||
String _toolSummary(AgentToolPart part) {
|
||||
final input = part.input;
|
||||
return (input['scriptName'] ??
|
||||
input['command'] ??
|
||||
input['path'] ??
|
||||
input['keyword'] ??
|
||||
'')
|
||||
.toString();
|
||||
final known =
|
||||
(input['scriptName'] ??
|
||||
input['command'] ??
|
||||
input['path'] ??
|
||||
input['keyword'] ??
|
||||
'')
|
||||
.toString();
|
||||
if (known.isNotEmpty) return known;
|
||||
for (final value in input.values) {
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
if (value is num || value is bool) return value.toString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _stringifyOutput(Object? output) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import '../../state/agent_providers.dart';
|
||||
import '../ai_agent/agent_models.dart';
|
||||
import '../ai_agent/agent_selection_sheet.dart';
|
||||
import '../ai_agent/agent_ui_tokens.dart';
|
||||
import 'agent_mcp_settings.dart';
|
||||
import 'widgets/settings_section.dart';
|
||||
|
||||
part 'ai_agent_settings_widgets.dart';
|
||||
@@ -21,7 +22,8 @@ class AiAgentSettingsPage extends ConsumerStatefulWidget {
|
||||
_AiAgentSettingsPageState();
|
||||
}
|
||||
|
||||
class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _url = TextEditingController();
|
||||
final _key = TextEditingController();
|
||||
@@ -37,6 +39,19 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
bool _modelsInvalid = false;
|
||||
List<AgentHostPolicy> _hostPolicies = const [];
|
||||
final Set<String> _changedHostIds = {};
|
||||
late final TabController _tabs;
|
||||
int _tabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs = TabController(length: 3, vsync: this)
|
||||
..addListener(() {
|
||||
if (!_tabs.indexIsChanging && _tabIndex != _tabs.index && mounted) {
|
||||
setState(() => _tabIndex = _tabs.index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -45,6 +60,7 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
_models.dispose();
|
||||
_contextLimit.dispose();
|
||||
_maxSteps.dispose();
|
||||
_tabs.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -75,8 +91,27 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
title: Text(l.tr('agent.settings')),
|
||||
bottom: TabBar(
|
||||
controller: _tabs,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: const Icon(Icons.memory_outlined, size: 19),
|
||||
text: l.tr('agent.settings.tab.provider'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.extension_outlined, size: 19),
|
||||
text: l.tr('agent.settings.tab.mcp'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined, size: 19),
|
||||
text: l.tr('agent.settings.tab.hosts'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: data == null
|
||||
bottomNavigationBar: data == null || _tabIndex == 1
|
||||
? null
|
||||
: _SaveBar(
|
||||
saving: _saving,
|
||||
@@ -90,107 +125,111 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
),
|
||||
data: (_) => Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
SettingsSection(
|
||||
title: l.tr('agent.settings.interface'),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 6, 12, 6),
|
||||
secondary: Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
title: Text(
|
||||
l.tr('agent.settings.showEntry'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Text(l.tr('agent.settings.showEntryHint')),
|
||||
),
|
||||
value: _nativeAgentEnabled,
|
||||
onChanged: _setEnabled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: l.tr('agent.settings.provider'),
|
||||
children: [
|
||||
_SettingsChoiceRow(
|
||||
key: const Key('agent-provider-row'),
|
||||
icon: Icons.hub_outlined,
|
||||
label: l.tr('agent.settings.providerType'),
|
||||
value: _providerLabel(_provider),
|
||||
onTap: _pickProvider,
|
||||
),
|
||||
_SettingsField(
|
||||
label: 'Base URL',
|
||||
controller: _url,
|
||||
hintText: _providerUrlHint,
|
||||
keyboardType: TextInputType.url,
|
||||
monospace: true,
|
||||
validator: (value) {
|
||||
final uri = Uri.tryParse(value?.trim() ?? '');
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https')
|
||||
? null
|
||||
: l.tr('agent.settings.invalidUrl');
|
||||
},
|
||||
),
|
||||
_SettingsField(
|
||||
label: 'API Key',
|
||||
controller: _key,
|
||||
obscureText: _obscureKey,
|
||||
monospace: true,
|
||||
suffix: IconButton(
|
||||
tooltip: _obscureKey
|
||||
? l.tr('common.show')
|
||||
: l.tr('common.hide'),
|
||||
onPressed: () =>
|
||||
setState(() => _obscureKey = !_obscureKey),
|
||||
icon: Icon(
|
||||
_obscureKey
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
validator: (value) => value?.trim().isNotEmpty == true
|
||||
? null
|
||||
: l.tr('agent.settings.required'),
|
||||
),
|
||||
_ModelSelectionRow(
|
||||
models: _parseModels(_models.text),
|
||||
invalid: _modelsInvalid,
|
||||
onTap: _pickModels,
|
||||
),
|
||||
_LimitsRow(contextLimit: _contextLimit, maxSteps: _maxSteps),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: l.tr('agent.settings.hostPolicies'),
|
||||
children: _hostPolicies.isEmpty
|
||||
? [
|
||||
_EmptyHosts(
|
||||
text: l.tr('agent.settings.hostPoliciesEmpty'),
|
||||
_settingsList([
|
||||
SettingsSection(
|
||||
title: l.tr('agent.settings.provider'),
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 6, 12, 6),
|
||||
secondary: Icon(
|
||||
Icons.smart_toy_outlined,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
]
|
||||
: _hostPolicies
|
||||
.map(
|
||||
(policy) => _HostPolicySummaryRow(
|
||||
key: ValueKey(policy.hostId),
|
||||
policy: policy,
|
||||
onTap: () => _editHostPolicy(policy),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
title: Text(
|
||||
l.tr('agent.settings.showEntry'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Text(l.tr('agent.settings.showEntryHint')),
|
||||
),
|
||||
value: _nativeAgentEnabled,
|
||||
onChanged: _setEnabled,
|
||||
),
|
||||
),
|
||||
_SettingsChoiceRow(
|
||||
key: const Key('agent-provider-row'),
|
||||
icon: Icons.hub_outlined,
|
||||
label: l.tr('agent.settings.providerType'),
|
||||
value: _providerLabel(_provider),
|
||||
onTap: _pickProvider,
|
||||
),
|
||||
_SettingsField(
|
||||
label: 'Base URL',
|
||||
controller: _url,
|
||||
hintText: _providerUrlHint,
|
||||
keyboardType: TextInputType.url,
|
||||
monospace: true,
|
||||
validator: (value) {
|
||||
final uri = Uri.tryParse(value?.trim() ?? '');
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https')
|
||||
? null
|
||||
: l.tr('agent.settings.invalidUrl');
|
||||
},
|
||||
),
|
||||
_SettingsField(
|
||||
label: 'API Key',
|
||||
controller: _key,
|
||||
obscureText: _obscureKey,
|
||||
monospace: true,
|
||||
suffix: IconButton(
|
||||
tooltip: _obscureKey
|
||||
? l.tr('common.show')
|
||||
: l.tr('common.hide'),
|
||||
onPressed: () =>
|
||||
setState(() => _obscureKey = !_obscureKey),
|
||||
icon: Icon(
|
||||
_obscureKey
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
validator: (value) => value?.trim().isNotEmpty == true
|
||||
? null
|
||||
: l.tr('agent.settings.required'),
|
||||
),
|
||||
_ModelSelectionRow(
|
||||
models: _parseModels(_models.text),
|
||||
invalid: _modelsInvalid,
|
||||
onTap: _pickModels,
|
||||
),
|
||||
_LimitsRow(
|
||||
contextLimit: _contextLimit,
|
||||
maxSteps: _maxSteps,
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
_tabIndex == 1
|
||||
? const AgentMcpSettings()
|
||||
: const SizedBox.shrink(),
|
||||
_settingsList([
|
||||
SettingsSection(
|
||||
title: l.tr('agent.settings.hostPolicies'),
|
||||
children: _hostPolicies.isEmpty
|
||||
? [
|
||||
_EmptyHosts(
|
||||
text: l.tr('agent.settings.hostPoliciesEmpty'),
|
||||
),
|
||||
]
|
||||
: _hostPolicies
|
||||
.map(
|
||||
(policy) => _HostPolicySummaryRow(
|
||||
key: ValueKey(policy.hostId),
|
||||
policy: policy,
|
||||
onTap: () => _editHostPolicy(policy),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -198,6 +237,12 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _settingsList(List<Widget> children) => ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: children,
|
||||
);
|
||||
|
||||
String get _providerUrlHint => switch (_provider) {
|
||||
'anthropic' => 'https://api.anthropic.com/v1',
|
||||
'google' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||
@@ -351,7 +396,8 @@ class _AiAgentSettingsPageState extends ConsumerState<AiAgentSettingsPage> {
|
||||
await ref.read(agentControllerProvider.notifier).refreshConnection();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
_changedHostIds.clear();
|
||||
_show(AppLocalizations.of(context).tr('common.saved'));
|
||||
} catch (error) {
|
||||
if (mounted) _show(error.toString());
|
||||
} finally {
|
||||
|
||||
@@ -638,6 +638,10 @@ const Map<String, String> stringsEn = {
|
||||
'agent.open': 'Open AI assistant',
|
||||
'agent.settings': 'AI Assistant',
|
||||
'agent.settings.subtitle': 'Provider, models, visibility and host policies',
|
||||
'agent.settings.tab.provider': 'Models',
|
||||
'agent.settings.tab.mcp': 'External MCP',
|
||||
'agent.settings.tab.hosts': 'Host policies',
|
||||
'agent.settings.tab.interface': 'Interface',
|
||||
'agent.history': 'Conversation history',
|
||||
'agent.historyEmpty': 'No conversations yet',
|
||||
'agent.historyMeta': '{0} messages',
|
||||
@@ -708,6 +712,7 @@ const Map<String, String> stringsEn = {
|
||||
'agent.effect.write': 'Read and write',
|
||||
'agent.approval.title': 'Your confirmation is required',
|
||||
'agent.approval.description': 'Run {1} on {0}',
|
||||
'agent.approval.providerDescription': 'Run {1} through {0}',
|
||||
'agent.approval.scope': 'Maximum operation scope: {0}',
|
||||
'agent.approval.targets': 'Affected targets',
|
||||
'agent.approval.sensitive':
|
||||
@@ -715,6 +720,7 @@ const Map<String, String> stringsEn = {
|
||||
'agent.approval.diff': 'Complete write diff',
|
||||
'agent.approval.allow': 'Allow',
|
||||
'agent.approval.allowSession': 'Allow the same operation this session',
|
||||
'agent.approval.allowMcpSession': 'Allow this tool for this session',
|
||||
'agent.approval.deny': 'Deny',
|
||||
'agent.notice.historyRepaired': 'Repaired {0} interrupted tool records.',
|
||||
'agent.notice.compacted': 'Older context was compacted ({0} messages).',
|
||||
@@ -782,4 +788,46 @@ const Map<String, String> stringsEn = {
|
||||
'The assistant can read and make changes after required approval.',
|
||||
'agent.settings.stopBeforeHide':
|
||||
'An AI task is still running. Stop it before hiding the global entry?',
|
||||
'agent.settings.mcp.title': 'External MCP',
|
||||
'agent.settings.mcp.subtitle':
|
||||
'Connect remote Streamable HTTP MCP servers. Tools are discovered on save and require confirmation before use.',
|
||||
'agent.settings.mcp.add': 'Add MCP Server',
|
||||
'agent.settings.mcp.edit': 'Edit MCP Server',
|
||||
'agent.settings.mcp.empty': 'No MCP servers configured',
|
||||
'agent.settings.mcp.name': 'Name',
|
||||
'agent.settings.mcp.key': 'Key',
|
||||
'agent.settings.mcp.keyHint':
|
||||
'Used for stable tool names and cannot be changed after creation.',
|
||||
'agent.settings.mcp.keyInvalid':
|
||||
'Use lowercase letters, numbers, underscores, or hyphens.',
|
||||
'agent.settings.mcp.connectTimeout': 'Connect timeout',
|
||||
'agent.settings.mcp.callTimeout': 'Call timeout',
|
||||
'agent.settings.mcp.seconds': 'sec',
|
||||
'agent.settings.mcp.timeoutInvalid': 'Enter a value from 1 to 60 seconds.',
|
||||
'agent.settings.mcp.authType': 'Authentication',
|
||||
'agent.settings.mcp.authNone': 'No authentication',
|
||||
'agent.settings.mcp.authKey': 'Key (Authorization)',
|
||||
'agent.settings.mcp.authOauth': 'OAuth 2.1 (coming later)',
|
||||
'agent.settings.mcp.secret': 'Key',
|
||||
'agent.settings.mcp.secretHint':
|
||||
'Sent as the Authorization header and stored encrypted.',
|
||||
'agent.settings.mcp.secretEditHint':
|
||||
'Leave blank to keep the current key, or enter a replacement.',
|
||||
'agent.settings.mcp.enabled': 'Enable this server',
|
||||
'agent.settings.mcp.test': 'Test connection',
|
||||
'agent.settings.mcp.testSuccess': 'Connected; discovered {0} tools',
|
||||
'agent.settings.mcp.saved': 'Saved; discovered {0} tools',
|
||||
'agent.settings.mcp.savedWithError':
|
||||
'Configuration saved and disabled; discovery failed: {0}',
|
||||
'agent.settings.mcp.discovered': 'Discovery complete; found {0} tools',
|
||||
'agent.settings.mcp.toolCount': '{0} of {1} tools enabled',
|
||||
'agent.settings.mcp.actionRefresh': 'Refresh',
|
||||
'agent.settings.mcp.actionManage': 'Manage',
|
||||
'agent.settings.mcp.actionEdit': 'Edit',
|
||||
'agent.settings.mcp.delete': 'Delete MCP Server',
|
||||
'agent.settings.mcp.deleteConfirm': 'Delete MCP Server “{0}” and its tools?',
|
||||
'agent.settings.mcp.toolsTitle': '{0} tools',
|
||||
'agent.settings.mcp.toolsHint':
|
||||
'Disable tools you do not want AI to use. External tools always require confirmation before use.',
|
||||
'agent.settings.mcp.saveTools': 'Save tool settings',
|
||||
};
|
||||
|
||||
@@ -607,6 +607,10 @@ const Map<String, String> stringsZh = {
|
||||
'agent.open': '打开 AI 助手',
|
||||
'agent.settings': 'AI 助手',
|
||||
'agent.settings.subtitle': 'Provider、模型、显示设置与主机策略',
|
||||
'agent.settings.tab.provider': '模型服务',
|
||||
'agent.settings.tab.mcp': '外部 MCP',
|
||||
'agent.settings.tab.hosts': '主机策略',
|
||||
'agent.settings.tab.interface': '界面显示',
|
||||
'agent.history': '历史会话',
|
||||
'agent.historyEmpty': '暂无历史会话',
|
||||
'agent.historyMeta': '{0} 条消息',
|
||||
@@ -672,12 +676,14 @@ const Map<String, String> stringsZh = {
|
||||
'agent.effect.write': '允许读写',
|
||||
'agent.approval.title': '需要你的确认',
|
||||
'agent.approval.description': '将在 {0} 上执行“{1}”',
|
||||
'agent.approval.providerDescription': '将通过 {0} 执行“{1}”',
|
||||
'agent.approval.scope': '操作范围:{0}',
|
||||
'agent.approval.targets': '影响目标',
|
||||
'agent.approval.sensitive': '批准后会把真实敏感内容发送给当前 AI Provider。',
|
||||
'agent.approval.diff': '完整写入差异',
|
||||
'agent.approval.allow': '允许',
|
||||
'agent.approval.allowSession': '本会话允许同一操作',
|
||||
'agent.approval.allowMcpSession': '本会话允许此工具',
|
||||
'agent.approval.deny': '拒绝',
|
||||
'agent.notice.historyRepaired': '已修复 {0} 条中断遗留的工具记录。',
|
||||
'agent.notice.compacted': '较早的上下文已压缩({0} 条消息)。',
|
||||
@@ -731,4 +737,40 @@ const Map<String, String> stringsZh = {
|
||||
'agent.settings.effect.readHint': '助手可以查看状态和文件,但不能进行修改。',
|
||||
'agent.settings.effect.writeHint': '助手可读取和修改内容,并按权限模式请求确认。',
|
||||
'agent.settings.stopBeforeHide': 'AI 任务仍在执行,是否停止任务后隐藏全局入口?',
|
||||
'agent.settings.mcp.title': '外部 MCP',
|
||||
'agent.settings.mcp.subtitle':
|
||||
'接入远程 Streamable HTTP MCP Server。保存后自动发现工具,调用前会请求确认。',
|
||||
'agent.settings.mcp.add': '添加 MCP Server',
|
||||
'agent.settings.mcp.edit': '编辑 MCP Server',
|
||||
'agent.settings.mcp.empty': '还没有配置 MCP Server',
|
||||
'agent.settings.mcp.name': '名称',
|
||||
'agent.settings.mcp.key': '标识',
|
||||
'agent.settings.mcp.keyHint': '用于生成稳定工具名称,创建后不可修改。',
|
||||
'agent.settings.mcp.keyInvalid': '仅支持小写字母、数字、下划线和连字符。',
|
||||
'agent.settings.mcp.connectTimeout': '连接超时',
|
||||
'agent.settings.mcp.callTimeout': '调用超时',
|
||||
'agent.settings.mcp.seconds': '秒',
|
||||
'agent.settings.mcp.timeoutInvalid': '请输入 1–60 秒。',
|
||||
'agent.settings.mcp.authType': '认证方式',
|
||||
'agent.settings.mcp.authNone': '无需认证',
|
||||
'agent.settings.mcp.authKey': '密钥 (Authorization)',
|
||||
'agent.settings.mcp.authOauth': 'OAuth 2.1(后续支持)',
|
||||
'agent.settings.mcp.secret': '密钥',
|
||||
'agent.settings.mcp.secretHint': '将作为 Authorization 请求头发送并加密保存。',
|
||||
'agent.settings.mcp.secretEditHint': '留空保持原密钥;输入新值则覆盖。',
|
||||
'agent.settings.mcp.enabled': '启用此 Server',
|
||||
'agent.settings.mcp.test': '测试连接',
|
||||
'agent.settings.mcp.testSuccess': '连接成功,发现 {0} 个工具',
|
||||
'agent.settings.mcp.saved': '保存成功,发现 {0} 个工具',
|
||||
'agent.settings.mcp.savedWithError': '配置已保存并停用,发现工具失败:{0}',
|
||||
'agent.settings.mcp.discovered': '重新发现完成,共 {0} 个工具',
|
||||
'agent.settings.mcp.toolCount': '已启用 {0} / {1} 个工具',
|
||||
'agent.settings.mcp.actionRefresh': '刷新',
|
||||
'agent.settings.mcp.actionManage': '管理',
|
||||
'agent.settings.mcp.actionEdit': '编辑',
|
||||
'agent.settings.mcp.delete': '删除 MCP Server',
|
||||
'agent.settings.mcp.deleteConfirm': '删除 MCP Server“{0}”及其工具?',
|
||||
'agent.settings.mcp.toolsTitle': '{0} 工具管理',
|
||||
'agent.settings.mcp.toolsHint': '关闭不希望 AI 使用的工具。所有外部工具在调用前都会请求确认。',
|
||||
'agent.settings.mcp.saveTools': '保存工具设置',
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/storage/app_storage.dart';
|
||||
import '../features/ai_agent/agent_controller.dart';
|
||||
import '../features/ai_agent/agent_mcp_models.dart';
|
||||
import '../features/ai_agent/agent_models.dart';
|
||||
import '../features/ai_agent/agent_repository.dart';
|
||||
import '../features/ai_agent/agent_socket_client.dart';
|
||||
@@ -105,6 +106,63 @@ final agentSettingsProvider =
|
||||
AgentSettingsNotifier.new,
|
||||
);
|
||||
|
||||
class AgentMcpServersNotifier
|
||||
extends AutoDisposeAsyncNotifier<List<AgentMcpServer>> {
|
||||
AgentRepository get _repository => ref.read(agentRepositoryProvider);
|
||||
|
||||
@override
|
||||
Future<List<AgentMcpServer>> build() => _repository.getMcpServers();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(build);
|
||||
}
|
||||
|
||||
Future<AgentMcpServer> create(Map<String, dynamic> payload) async {
|
||||
final server = await _repository.createMcpServer(payload);
|
||||
state = AsyncData([...state.valueOrNull ?? const [], server]);
|
||||
return server;
|
||||
}
|
||||
|
||||
Future<AgentMcpServer> save(String id, Map<String, dynamic> payload) async {
|
||||
final server = await _repository.updateMcpServer(id, payload);
|
||||
_replace(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
Future<int> testConnection(Map<String, dynamic> payload) =>
|
||||
_repository.testMcpConnection(payload);
|
||||
|
||||
Future<AgentMcpServer> discover(String id) async {
|
||||
final server = await _repository.discoverMcpServer(id);
|
||||
_replace(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
await _repository.deleteMcpServer(id);
|
||||
state = AsyncData(
|
||||
(state.valueOrNull ?? const [])
|
||||
.where((server) => server.id != id)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
void _replace(AgentMcpServer server) {
|
||||
state = AsyncData(
|
||||
(state.valueOrNull ?? const [])
|
||||
.map((item) => item.id == server.id ? server : item)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final agentMcpServersProvider =
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
AgentMcpServersNotifier,
|
||||
List<AgentMcpServer>
|
||||
>(AgentMcpServersNotifier.new);
|
||||
|
||||
final agentHostPoliciesProvider = Provider<List<AgentHostPolicy>>((ref) {
|
||||
final hosts = ref.watch(hostListProvider).valueOrNull;
|
||||
if (hosts == null) {
|
||||
|
||||
@@ -56,4 +56,46 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('MCP approval names the provider and offers tool grant', (
|
||||
tester,
|
||||
) async {
|
||||
final approval = AgentApproval(
|
||||
requestId: 'approval-mcp',
|
||||
toolCallId: 'tool-mcp',
|
||||
tool: 'mcp_anysearch_search',
|
||||
input: const {'query': 'EasyNode'},
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
providerName: 'AnySearch',
|
||||
toolInfo: const {
|
||||
'source': 'mcp',
|
||||
'providerName': 'AnySearch',
|
||||
'displayName': 'Search',
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
extensions: const [AppColorTheme.defaultLight],
|
||||
),
|
||||
home: Scaffold(body: AgentApprovalCard(approval: approval)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Run Search through AnySearch'), findsOneWidget);
|
||||
expect(find.text('Allow this tool for this session'), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:easynode_native/features/ai_agent/agent_mcp_models.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('parses MCP servers, tools and public authorization metadata', () {
|
||||
final server = AgentMcpServer.fromJson({
|
||||
'_id': 'server-1',
|
||||
'name': 'AnySearch',
|
||||
'key': 'anysearch',
|
||||
'url': 'https://example.com/mcp',
|
||||
'enabled': true,
|
||||
'connectTimeoutSeconds': 300,
|
||||
'callTimeoutSeconds': 0,
|
||||
'headers': {'Authorization': ''},
|
||||
'tools': [
|
||||
{
|
||||
'remoteName': 'search',
|
||||
'exposedName': 'mcp_anysearch_search',
|
||||
'displayName': 'Search',
|
||||
'description': 'Search the web',
|
||||
'inputSchema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {'type': 'string'},
|
||||
},
|
||||
},
|
||||
'enabled': true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(server.id, 'server-1');
|
||||
expect(server.hasAuthorization, isTrue);
|
||||
expect(server.connectTimeoutSeconds, 60);
|
||||
expect(server.callTimeoutSeconds, 1);
|
||||
expect(server.enabledToolCount, 1);
|
||||
expect(server.tools.single.displayName, 'Search');
|
||||
expect(server.tools.single.toUpdateJson(), {
|
||||
'remoteName': 'search',
|
||||
'enabled': true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -77,6 +77,48 @@ void main() {
|
||||
expect(state.pendingApprovals, isEmpty);
|
||||
});
|
||||
|
||||
test('keeps MCP provider metadata through approval and tool events', () {
|
||||
var state = AgentConversationState(
|
||||
messages: [createUserAgentMessage('search the web')],
|
||||
);
|
||||
state = applyAgentEvent(state, const {'type': 'turn_start'});
|
||||
state = applyAgentEvent(state, const {
|
||||
'type': 'approval_request',
|
||||
'requestId': 'approval-mcp',
|
||||
'toolCallId': 'tool-mcp',
|
||||
'tool': 'mcp_anysearch_search',
|
||||
'providerName': 'AnySearch',
|
||||
'toolInfo': {
|
||||
'source': 'mcp',
|
||||
'providerName': 'AnySearch',
|
||||
'displayName': 'Search',
|
||||
'remoteName': 'search',
|
||||
},
|
||||
'input': {'query': 'EasyNode'},
|
||||
});
|
||||
state = applyAgentEvent(state, const {
|
||||
'type': 'tool_call',
|
||||
'toolCallId': 'tool-mcp',
|
||||
'tool': 'mcp_anysearch_search',
|
||||
'toolInfo': {
|
||||
'source': 'mcp',
|
||||
'providerName': 'AnySearch',
|
||||
'displayName': 'Search',
|
||||
'remoteName': 'search',
|
||||
},
|
||||
'input': {'query': 'EasyNode'},
|
||||
});
|
||||
|
||||
final approval = state.pendingApprovals.single;
|
||||
final tool = state.messages.last.parts.single as AgentToolPart;
|
||||
expect(approval.isMcp, isTrue);
|
||||
expect(approval.providerName, 'AnySearch');
|
||||
expect(approval.displayName, 'Search');
|
||||
expect(tool.isMcp, isTrue);
|
||||
expect(tool.providerName, 'AnySearch');
|
||||
expect(tool.displayName, 'Search');
|
||||
});
|
||||
|
||||
test('marks a Plus-restricted tool as denied instead of running forever', () {
|
||||
var state = AgentConversationState(
|
||||
messages: [createUserAgentMessage('restart service')],
|
||||
|
||||
@@ -97,4 +97,43 @@ void main() {
|
||||
expect(tester.widget<SingleChildScrollView>(scroll).primary, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('MCP tool card shows provider and remote display name', (
|
||||
tester,
|
||||
) async {
|
||||
const part = AgentToolPart(
|
||||
toolCallId: 'mcp-tool',
|
||||
tool: 'mcp_anysearch_search_1234',
|
||||
input: {'query': 'EasyNode'},
|
||||
status: AgentToolStatus.done,
|
||||
output: {'content': 'ok'},
|
||||
toolInfo: {
|
||||
'source': 'mcp',
|
||||
'providerName': 'AnySearch',
|
||||
'displayName': 'Search',
|
||||
},
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
extensions: const [AppColorTheme.defaultLight],
|
||||
),
|
||||
home: const Scaffold(body: AgentToolCard(part: part)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
expect(find.text('MCP · AnySearch'), findsOneWidget);
|
||||
expect(find.text('EasyNode'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:easynode_native/core/ui/app_color_theme.dart';
|
||||
import 'package:easynode_native/features/ai_agent/agent_models.dart';
|
||||
import 'package:easynode_native/features/ai_agent/agent_mcp_models.dart';
|
||||
import 'package:easynode_native/features/settings/ai_agent_settings_page.dart';
|
||||
import 'package:easynode_native/l10n/app_localizations.dart';
|
||||
import 'package:easynode_native/state/agent_providers.dart';
|
||||
@@ -66,9 +67,33 @@ class _FakeDisabledSettingsNotifier extends AgentSettingsNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeMcpServersNotifier extends AgentMcpServersNotifier {
|
||||
@override
|
||||
Future<List<AgentMcpServer>> build() async => [
|
||||
AgentMcpServer.fromJson({
|
||||
'_id': 'mcp-1',
|
||||
'name': 'AnySearch',
|
||||
'key': 'anysearch',
|
||||
'url': 'https://example.com/mcp',
|
||||
'enabled': true,
|
||||
'headers': {'Authorization': ''},
|
||||
'tools': [
|
||||
{
|
||||
'remoteName': 'search',
|
||||
'exposedName': 'mcp_anysearch_search',
|
||||
'displayName': 'Search',
|
||||
'enabled': true,
|
||||
'inputSchema': {'type': 'object'},
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _app({double textScale = 1}) => ProviderScope(
|
||||
overrides: [
|
||||
agentSettingsProvider.overrideWith(_FakeAgentSettingsNotifier.new),
|
||||
agentMcpServersProvider.overrideWith(_FakeMcpServersNotifier.new),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
@@ -107,11 +132,8 @@ void main() {
|
||||
|
||||
expect(find.text('显示全局 AI 助手'), findsOneWidget);
|
||||
expect(find.text('OpenAI Compatible'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Production server with a long name'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.text('主机策略'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Production server with a long name'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -142,11 +164,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Anthropic'), findsOneWidget);
|
||||
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Production server with a long name'),
|
||||
300,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.text('主机策略'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Production server with a long name'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('允许 AI 使用此主机'), findsOneWidget);
|
||||
@@ -161,6 +180,38 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('MCP tab shows configured servers and editor', (tester) async {
|
||||
await tester.pumpWidget(_app());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('外部 MCP'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AnySearch'), findsOneWidget);
|
||||
expect(find.text('已启用 1 / 1 个工具'), findsOneWidget);
|
||||
expect(find.byKey(const Key('agent-mcp-add')), findsOneWidget);
|
||||
final actionCenters = ['刷新', '管理', '编辑', '删除']
|
||||
.map((label) => tester.getCenter(find.text(label)))
|
||||
.toList(growable: false);
|
||||
expect(
|
||||
actionCenters.map((center) => center.dy).toSet().length,
|
||||
1,
|
||||
reason: 'MCP card actions should stay on one row',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('编辑'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final authorization = tester.widget<EditableText>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('agent-mcp-authorization')),
|
||||
matching: find.byType(EditableText),
|
||||
),
|
||||
);
|
||||
expect(authorization.obscureText, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('model sheet supports search and custom model IDs', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -221,7 +272,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('successful save returns without a success toast', (
|
||||
testWidgets('successful save keeps settings open and confirms the save', (
|
||||
tester,
|
||||
) async {
|
||||
_FakeDisabledSettingsNotifier.saveCalls = 0;
|
||||
@@ -268,8 +319,9 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_FakeDisabledSettingsNotifier.saveCalls, 1);
|
||||
expect(find.text('Open settings'), findsOneWidget);
|
||||
expect(find.text('已保存'), findsNothing);
|
||||
expect(find.byType(AiAgentSettingsPage), findsOneWidget);
|
||||
expect(find.text('Open settings'), findsNothing);
|
||||
expect(find.text('已保存'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :loading="discoveringId === row._id" @click="discover(row)">重新发现</el-button>
|
||||
<el-button v-if="row.tools?.length" link @click="openTools(row)">工具管理</el-button>
|
||||
<el-button link type="primary" :loading="discoveringId === row._id" @click="discover(row)">刷新</el-button>
|
||||
<el-button v-if="row.tools?.length" link @click="openTools(row)">管理</el-button>
|
||||
<el-button link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -110,7 +110,11 @@
|
||||
<code>{{ row.remoteName }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="270" show-overflow-tooltip prop="description" />
|
||||
<el-table-column label="说明" min-width="270">
|
||||
<template #default="{ row }">
|
||||
<div class="tool_description">{{ row.description || '-' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="76" align="center">
|
||||
<template #default="{ row }"><el-switch v-model="row.enabled" /></template>
|
||||
</el-table-column>
|
||||
@@ -341,8 +345,6 @@ onMounted(loadServers)
|
||||
.settings_section {
|
||||
margin-bottom: 28px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
@@ -366,6 +368,7 @@ onMounted(loadServers)
|
||||
.field_tip, .tools_tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
||||
.tools_tip { margin: 0 0 12px; }
|
||||
.tool_name { font-weight: 500; }
|
||||
.tool_description { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.schema_preview { padding: 4px 18px 14px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.schema_preview pre { margin: 8px 0 0; padding: 10px; border-radius: 6px; background: var(--el-fill-color-light); overflow: auto; }
|
||||
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<template>
|
||||
<div class="ai_agent_settings">
|
||||
<header class="settings_header">
|
||||
<div>
|
||||
<h2>AI 助手</h2>
|
||||
<p>分别管理模型服务、外部能力、主机权限和界面入口。各区域独立保存,互不影响。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeSection" class="settings_tabs">
|
||||
<el-tab-pane name="provider">
|
||||
<template #label>
|
||||
<span class="tab_label"><el-icon><Cpu /></el-icon>模型服务</span>
|
||||
</template>
|
||||
<section class="settings_section">
|
||||
<div class="interface_setting_row">
|
||||
<div>
|
||||
<h4>AI 助手入口</h4>
|
||||
<p>控制登录后各页面是否显示 AI 助手宠物入口,不影响 Web 终端助手。</p>
|
||||
</div>
|
||||
<div class="interface_switch">
|
||||
<el-switch
|
||||
:model-value="petEnabled"
|
||||
:loading="savingPreferences"
|
||||
@change="savePetEnabled"
|
||||
/>
|
||||
<span :class="{ 'is_active': petEnabled }">{{ petEnabled ? '已显示' : '已隐藏' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section_head">
|
||||
<div>
|
||||
<h3>Provider 设置</h3>
|
||||
@@ -127,6 +135,7 @@
|
||||
<el-button type="primary" :loading="savingProvider" @click="saveProvider">保存 Provider 设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -202,34 +211,13 @@
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="appearance" lazy>
|
||||
<template #label>
|
||||
<span class="tab_label"><el-icon><Monitor /></el-icon>界面显示</span>
|
||||
</template>
|
||||
<section class="settings_section appearance_section">
|
||||
<div class="section_head interface_setting_head">
|
||||
<div>
|
||||
<h3>AI 助手入口</h3>
|
||||
<p>控制登录后各页面是否显示 AI 助手宠物入口,不影响 Web 终端助手。</p>
|
||||
</div>
|
||||
<div class="interface_switch">
|
||||
<el-switch
|
||||
:model-value="petEnabled"
|
||||
:loading="savingPreferences"
|
||||
@change="savePetEnabled"
|
||||
/>
|
||||
<span :class="{ 'is_active': petEnabled }">{{ petEnabled ? '已显示' : '已隐藏' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue'
|
||||
import { Connection, Cpu, Lock, Monitor } from '@element-plus/icons-vue'
|
||||
import { Connection, Cpu, Lock } from '@element-plus/icons-vue'
|
||||
import AgentMcp from './agent-mcp.vue'
|
||||
|
||||
const { proxy: { $api, $message, $store } } = getCurrentInstance()
|
||||
@@ -259,7 +247,7 @@ const DEFAULT_HOST_POLICY = {
|
||||
|
||||
const DEFAULT_CONTEXT_LIMIT = 64 * 1024
|
||||
const DEFAULT_MAX_STEPS = 25
|
||||
const SETTING_SECTIONS = new Set(['provider', 'mcp', 'hosts', 'appearance'])
|
||||
const SETTING_SECTIONS = new Set(['provider', 'mcp', 'hosts'])
|
||||
const savedSection = localStorage.getItem('aiAgentSettingsSection')
|
||||
|
||||
const activeSection = ref(SETTING_SECTIONS.has(savedSection) ? savedSection : 'provider')
|
||||
@@ -436,17 +424,6 @@ onMounted(async () => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings_header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
h2 { margin: 0 0 7px; font-size: 20px; font-weight: 600; }
|
||||
p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
|
||||
}
|
||||
|
||||
.settings_tabs {
|
||||
:deep(.el-tabs__header) { margin: 0 0 18px; }
|
||||
:deep(.el-tabs__nav-wrap::after) { height: 1px; }
|
||||
@@ -463,8 +440,6 @@ onMounted(async () => {
|
||||
.settings_section {
|
||||
margin-bottom: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
@@ -476,12 +451,18 @@ onMounted(async () => {
|
||||
p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
|
||||
}
|
||||
|
||||
.interface_setting_head {
|
||||
.interface_setting_row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 0;
|
||||
max-width: 760px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
h4 { margin: 0 0 5px; font-size: 14px; }
|
||||
p { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; }
|
||||
}
|
||||
|
||||
.interface_switch {
|
||||
@@ -526,14 +507,13 @@ onMounted(async () => {
|
||||
.host_addr { margin-top: 3px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings_header { margin-bottom: 2px; }
|
||||
.settings_tabs :deep(.el-tabs__item) { height: 44px; padding: 0 14px; }
|
||||
.tab_label { gap: 5px; font-size: 13px; }
|
||||
.settings_section { padding: 14px; }
|
||||
.host_policy_section { overflow-x: auto; }
|
||||
.host_policy_table { min-width: 760px; }
|
||||
.models_input_wrap { align-items: flex-start; flex-direction: column; }
|
||||
.interface_setting_head { align-items: flex-start; flex-direction: column; }
|
||||
.interface_setting_row { align-items: flex-start; flex-direction: column; }
|
||||
:deep(.provider_form .el-form-item) { display: block; }
|
||||
:deep(.provider_form .el-form-item__label) { width: auto !important; justify-content: flex-start; }
|
||||
:deep(.provider_form .el-form-item__content) { margin-left: 0 !important; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="setting_container">
|
||||
<el-tabs v-model="tabKey" tab-position="top">
|
||||
<el-tabs v-model="tabKey" tab-position="left" class="setting_tabs">
|
||||
<el-tab-pane label="修改密码" name="user">
|
||||
<User />
|
||||
</el-tab-pane>
|
||||
@@ -55,6 +55,41 @@ watch(() => tabKey.value, (newVal) => {
|
||||
.setting_container {
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.setting_tabs {
|
||||
height: 100%;
|
||||
|
||||
> :deep(.el-tabs__header.is-left) {
|
||||
width: 132px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
> :deep(.el-tabs__header.is-left .el-tabs__item) {
|
||||
justify-content: flex-start;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
> :deep(.el-tabs__header.is-left .el-tabs__nav-wrap::after) { width: 1px; }
|
||||
> :deep(.el-tabs__header.is-left .el-tabs__active-bar) { width: 2px; }
|
||||
|
||||
> :deep(.el-tabs__content) {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.setting_tabs > :deep(.el-tabs__header.is-left) {
|
||||
width: 112px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.setting_tabs > :deep(.el-tabs__header.is-left .el-tabs__item) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user