mcp.dart 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:provider/provider.dart';
  4. import 'settings.dart';
  5. /// MCP 服务器的控制通道。
  6. const MethodChannel mcpChannel = MethodChannel('com.nonvioce.cal/mcp');
  7. /// MCP 服务器的状态。
  8. class McpStatus {
  9. final bool running;
  10. final int port;
  11. final List<String> tools;
  12. final String? error;
  13. const McpStatus({
  14. required this.running,
  15. required this.port,
  16. this.tools = const [],
  17. this.error,
  18. });
  19. /// 局域网地址。真正的 IP 由服务端在状态里给出,这里只是兜底显示。
  20. String get endpoint => 'http://<本机 IP>:$port/mcp';
  21. static const idle = McpStatus(running: false, port: 0);
  22. }
  23. /// 读写 MCP 服务器状态。
  24. ///
  25. /// 按设计(Q18/Q25),MCP 是**只读 + 求值**的服务:AI 客户端能算、能取会话状态、
  26. /// 能读语法文档,但**不能**往里塞代码或文件 —— 插件相关的五个工具已经全部删掉,
  27. /// 也没有 Rhino。
  28. class McpController extends ChangeNotifier {
  29. McpStatus status = McpStatus.idle;
  30. bool busy = false;
  31. Future<void> refresh() async {
  32. try {
  33. final m = await mcpChannel.invokeMapMethod<String, dynamic>('status');
  34. status = _parse(m);
  35. } on PlatformException catch (e) {
  36. status = McpStatus(running: false, port: 0, error: e.message);
  37. }
  38. notifyListeners();
  39. }
  40. /// 启动。[port] 来自设置页,空则用原生侧的默认端口。
  41. ///
  42. /// 服务已经在跑而端口又变了的时候,原生侧会先停再起 —— 否则新端口不生效。
  43. Future<void> start({int? port}) async {
  44. busy = true;
  45. notifyListeners();
  46. try {
  47. final m = await mcpChannel.invokeMapMethod<String, dynamic>(
  48. 'start',
  49. <String, dynamic>{'port': ?port},
  50. );
  51. status = _parse(m);
  52. } on PlatformException catch (e) {
  53. status = McpStatus(running: false, port: 0, error: e.message);
  54. }
  55. busy = false;
  56. notifyListeners();
  57. }
  58. Future<void> stop() async {
  59. busy = true;
  60. notifyListeners();
  61. try {
  62. await mcpChannel.invokeMethod<Object?>('stop');
  63. status = McpStatus.idle;
  64. } on PlatformException catch (e) {
  65. status = McpStatus(running: false, port: 0, error: e.message);
  66. }
  67. busy = false;
  68. notifyListeners();
  69. }
  70. static McpStatus _parse(Map<String, dynamic>? m) {
  71. if (m == null) return McpStatus.idle;
  72. return McpStatus(
  73. running: m['running'] == true,
  74. port: (m['port'] as num?)?.toInt() ?? 0,
  75. tools: ((m['tools'] as List?) ?? const []).map((e) => '$e').toList(),
  76. error: m['error'] as String?,
  77. );
  78. }
  79. }
  80. /// 弹出 MCP 控制面板。控制台的 `mcp` 命令走这里。
  81. Future<void> showMcpPanel(BuildContext context) async {
  82. final ctrl = McpController();
  83. await ctrl.refresh();
  84. if (!context.mounted) return;
  85. await showModalBottomSheet<void>(
  86. context: context,
  87. showDragHandle: true,
  88. builder: (_) => ChangeNotifierProvider.value(
  89. value: ctrl,
  90. child: const _McpSheet(),
  91. ),
  92. );
  93. }
  94. class _McpSheet extends StatelessWidget {
  95. const _McpSheet();
  96. @override
  97. Widget build(BuildContext context) {
  98. final ctrl = context.watch<McpController>();
  99. final s = ctrl.status;
  100. final scheme = Theme.of(context).colorScheme;
  101. return SafeArea(
  102. child: Padding(
  103. padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
  104. child: Column(
  105. mainAxisSize: MainAxisSize.min,
  106. crossAxisAlignment: CrossAxisAlignment.start,
  107. children: [
  108. Row(
  109. children: [
  110. Icon(
  111. s.running ? Icons.cloud_done_outlined : Icons.cloud_off_outlined,
  112. color: s.running ? scheme.primary : scheme.outline,
  113. ),
  114. const SizedBox(width: 8),
  115. Text('MCP 服务器',
  116. style: Theme.of(context).textTheme.titleMedium),
  117. const Spacer(),
  118. if (s.running)
  119. Text('端口 ${s.port}',
  120. style: const TextStyle(fontFamily: 'monospace')),
  121. ],
  122. ),
  123. const SizedBox(height: 12),
  124. const Text(
  125. '把计算引擎开放给 AI 客户端。只读 + 求值:'
  126. '对方可以计算、读取当前变量和语法文档,但不能创建插件或写入文件。',
  127. style: TextStyle(fontSize: 13),
  128. ),
  129. if (s.error != null) ...[
  130. const SizedBox(height: 10),
  131. Text(s.error!,
  132. style: TextStyle(color: scheme.error, fontSize: 13)),
  133. ],
  134. if (s.running && s.tools.isNotEmpty) ...[
  135. const SizedBox(height: 12),
  136. Text('暴露的工具(${s.tools.length})',
  137. style: Theme.of(context).textTheme.labelLarge),
  138. const SizedBox(height: 6),
  139. Wrap(
  140. spacing: 6,
  141. runSpacing: 6,
  142. children: [
  143. for (final t in s.tools)
  144. Chip(
  145. label: Text(t, style: const TextStyle(fontSize: 12)),
  146. visualDensity: VisualDensity.compact,
  147. materialTapTargetSize:
  148. MaterialTapTargetSize.shrinkWrap,
  149. ),
  150. ],
  151. ),
  152. const SizedBox(height: 12),
  153. SelectableText(
  154. s.endpoint,
  155. style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
  156. ),
  157. ],
  158. const SizedBox(height: 16),
  159. Row(
  160. children: [
  161. FilledButton.icon(
  162. onPressed: ctrl.busy
  163. ? null
  164. : () => s.running
  165. ? ctrl.stop()
  166. : ctrl.start(port: context.read<AppSettings>().mcpPort),
  167. icon: Icon(s.running ? Icons.stop : Icons.play_arrow),
  168. label: Text(s.running ? '停止' : '启动'),
  169. ),
  170. const SizedBox(width: 12),
  171. TextButton(
  172. onPressed: ctrl.busy ? null : ctrl.refresh,
  173. child: const Text('刷新'),
  174. ),
  175. ],
  176. ),
  177. ],
  178. ),
  179. ),
  180. );
  181. }
  182. }