feat: native 端支持外接 mcp 操作

This commit is contained in:
chaoszhu
2026-08-29 19:46:45 +08:00
parent d848a0b6aa
commit 932fb708c4
20 changed files with 1932 additions and 190 deletions
+28
View File
@@ -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 {
+48
View File
@@ -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',
};
+42
View File
@@ -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': '保存工具设置',
};
+58
View File
@@ -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; }
+27 -47
View File
@@ -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; }
+37 -2
View File
@@ -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>